diff --git a/.gitignore b/.gitignore index 4ede3b26..8826dd40 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,17 @@ .cxx Jenkinsfile -Jenkinsfile_Archive -Jenkinsfile_GitHubPublish -Jenkinsfile_Desktop -Jenkinsfile_GitHubPublishFasttrack +Nightly.Jenkinsfile +Archive.Jenkinsfile +GitHubPublish.Jenkinsfile +Desktop.Jenkinsfile +UpdateTools.Jenkinsfile +DependencyReport.Jenkinsfile +EspressoTest.Jenkinsfile +Release.Jenkinsfile +Multibranch.Jenkinsfile +ci ci-overrides.properties nexus-init.gradle.kts +doc +android/src/androidTest diff --git a/README.md b/README.md index ecb26d4b..68e1d691 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,4 @@ -# eRezept App - -**New Features in 1.1.0:** -- Authentication without electronical health card: Fasttrack (Feature flagged) -- Manage multiple profiles (Feature flagged) -- Desktop Client - +# E-Rezept App ## Introduction Prescriptions for medicines that are only available in pharmacies can be issued as electronic prescriptions (e-prescriptions resp. E-Rezepte) for people with public health insurance from 1 July 2021. @@ -103,6 +97,10 @@ gradle :android:assemble(Google|Huawei)Pu(External|Internal)(Debug|Release) -Pbu The resulting `.apk` can be found in e.g. `android/build/outputs/apk/googlePuExternal/debug/`. +#### Visualize Test Tags + +See [Visualize Test Tags](documentation/test-tags.md) + ### Desktop To build a fat JAR run: diff --git a/ReleaseNotes.md b/ReleaseNotes.md index a8ee68fa..64bfac71 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,3 +1,13 @@ +# Release 1.4.9 +### Added + +- Pharmacies can be searched for on a map +- New users now get a better onboarding in the app + +### Fixed + +- Several bugfixes + # Release 1.2.1-SRC Codebase for SRC review diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 99b52b75..3d48c419 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,6 +1,5 @@ import de.gematik.ti.erp.app import de.gematik.ti.erp.overriding -import org.jetbrains.compose.compose import org.owasp.dependencycheck.reporting.ReportGenerator.Format import java.util.Properties @@ -8,22 +7,21 @@ plugins { id("com.android.application") kotlin("android") id("org.jetbrains.compose") - kotlin("kapt") + kotlin("plugin.serialization") + id("io.realm.kotlin") id("kotlin-parcelize") id("org.owasp.dependencycheck") id("com.jaredsburrows.license") id("de.gematik.ti.erp.dependencies") - id("dagger.hilt.android.plugin") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } -val USER_AGENT: String by overriding() val VERSION_CODE: String by overriding() val VERSION_NAME: String by overriding() -val DEBUG_TEST_IDS_ENABLED: String by overriding() -val VAU_OCSP_RESPONSE_MAX_AGE: String by overriding() +val TEST_INSTRUMENTATION_ORCHESTRATOR: String? by project afterEvaluate { - val taskRegEx = """assemble(Google|Huawei)(PuDebug|PuRelease)""".toRegex() + val taskRegEx = """assemble(Google|Huawei)(PuExternalDebug|PuExternalRelease)""".toRegex() tasks.forEach { task -> taskRegEx.matchEntire(task.name)?.let { val (_, version, flavor) = it.groupValues @@ -32,66 +30,44 @@ afterEvaluate { } } +tasks.named("preBuild") { + dependsOn(":ktlint", ":detekt") +} + licenseReport { generateCsvReport = false - generateHtmlReport = true - generateJsonReport = false - copyHtmlReportToAssets = true + generateHtmlReport = false + generateJsonReport = true + copyJsonReportToAssets = true } android { - // currently not working with an app suffix - // namespace = "de.gematik.ti.erp.app" + namespace = "de.gematik.ti.erp.app" defaultConfig { applicationId = "de.gematik.ti.erp.app" versionCode = VERSION_CODE.toInt() versionName = VERSION_NAME - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testApplicationId = "de.gematik.ti.erp.app.test.test" + testInstrumentationRunner = TEST_INSTRUMENTATION_ORCHESTRATOR testInstrumentationRunnerArguments += "clearPackageData" to "true" testInstrumentationRunnerArguments += "useTestStorageService" to "true" - - javaCompileOptions { - annotationProcessorOptions { - arguments += "room.schemaLocation" to "$projectDir/schemas" - } - } - } - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } - } - - sourceSets { - val test by getting - test.apply { - java.srcDirs("src/sharedTest/java") - resources.srcDirs("src/test/res") - } - val androidTest by getting - androidTest.apply { - java.srcDirs("src/sharedTest/java") - resources.srcDirs("src/test/res") - assets.srcDirs("$projectDir/schemas") - } } androidResources { - noCompress("srt", "csv") + noCompress("srt", "csv", "json") } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "1.8" freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" - freeCompilerArgs += "-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi" } testOptions { @@ -137,6 +113,7 @@ android { } else { println("No signing properties found!") } + buildTypes { val release by getting { isMinifyEnabled = true @@ -178,11 +155,19 @@ android { signingConfig = signingConfigs.findByName("huaweiRelease") } } - if (flavor?.startsWith("konnektathon") == true) { + if (flavor?.startsWith("konnektathonRu") == true) { + create(flavor) { + dimension = "version" + applicationIdSuffix = ".konnektathon.ru" + versionNameSuffix = "-konnektathon-RU" + signingConfig = signingConfigs.findByName("googleRelease") + } + } + if (flavor?.startsWith("konnektathonDevru") == true) { create(flavor) { dimension = "version" - applicationIdSuffix = ".konnektathon" - versionNameSuffix = "-konnektathon" + applicationIdSuffix = ".konnektathon.rudev" + versionNameSuffix = "-konnektathon-RUDEV" signingConfig = signingConfigs.findByName("googleRelease") } } @@ -200,22 +185,29 @@ android { pickFirsts += "win32-x86/attach_hotspot_windows.dll" } } + + composeOptions { + kotlinCompilerExtensionVersion = "1.3.0" + } } -// compose { -// android.useAndroidX = true -// } +compose.android.useAndroidX = true +compose.android.androidxVersion = app.composeVersion dependencies { implementation(project(":common")) + testImplementation(project(":common")) implementation(kotlin("stdlib")) implementation(kotlin("reflect")) testImplementation(kotlin("test")) + implementation("com.google.maps.android:maps-compose:2.7.2") + implementation("com.google.maps.android:maps-ktx:3.3.0") + implementation("com.google.maps.android:maps-utils-ktx:3.3.0") + implementation("com.google.maps.android:android-maps-utils:2.4.0") + implementation("com.google.android.gms:play-services-maps:18.0.2") + app { - tracker { - implementation(piwik) - } dataMatrix { implementation(mlkitBarcodeScanner) implementation(zxing) @@ -233,7 +225,7 @@ dependencies { implementation(datastorePreferences) implementation(security) implementation(biometric) - + implementation(webkit) implementation(lifecycle("viewmodel-compose")) implementation(lifecycle("process")) { // FIXME: remove if AGP > 7.2.0-alpha05 can handle cyclic dependencies (again) @@ -241,58 +233,60 @@ dependencies { } implementation(composeNavigation) - implementation(composeHiltNavigation) implementation(composeActivity) implementation(composePaging) - implementation(constraintLayout) implementation(camera("camera2")) implementation(camera("lifecycle")) implementation(camera("view", cameraViewVersion)) + implementation(imageCropper) debugImplementation(processPhoenix) } dependencyInjection { - implementation(hilt("android")) - kapt(hilt("compiler")) + compileOnly(kodein("di-framework-compose")) + androidTestImplementation(kodein("di-framework-compose")) } logging { - implementation(timber) + implementation(napier) + } + lottie { + implementation(lottie) } serialization { - implementation(moshi("moshi")) - kapt(moshi("moshi-kotlin-codegen")) - - implementation(fhir) + implementation(kotlinXJson) } crypto { implementation(jose4j) implementation(bouncyCastle("bcprov")) implementation(bouncyCastle("bcpkix")) + testImplementation(bouncyCastle("bcprov", "jdk15on")) + testImplementation(bouncyCastle("bcpkix", "jdk15on")) } network { implementation(retrofit2("retrofit")) - implementation(retrofit2("converter-moshi")) + implementation(retrofit2KotlinXSerialization) implementation(okhttp3("okhttp")) implementation(okhttp3("logging-interceptor")) + + androidTestImplementation(okhttp3("okhttp")) } database { - implementation(sqlCipher) - implementation(room("runtime")) - implementation(room("ktx")) - kapt(room("compiler")) + compileOnly(realm) + testCompileOnly(realm) } compose { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material) - implementation(compose.materialIconsExtended) - implementation(compose.uiTooling) + implementation(runtime) + implementation(foundation) + implementation(material) + implementation(materialIconsExtended) + implementation(animation) + implementation(uiTooling) + implementation(preview) + implementation(accompanist("swiperefresh")) implementation(accompanist("flowlayout")) implementation(accompanist("pager")) implementation(accompanist("pager-indicators")) - implementation(accompanist("insets")) - implementation(accompanist("insets-ui")) implementation(accompanist("systemuicontroller")) } passwordStrength { @@ -301,19 +295,23 @@ dependencies { playServices { implementation(location) implementation(safetynet) + implementation(appReview) + implementation(appUpdate) } androidTest { testImplementation(archCore) androidTestImplementation(core) + androidTestImplementation(rules) androidTestImplementation(junitExt) androidTestImplementation(runner) androidTestUtil(orchestrator) + androidTestUtil(services) androidTestImplementation(navigation) androidTestImplementation(espresso) } kotlinXTest { - implementation(coroutinesTest) + testImplementation(coroutinesTest) } composeTest { androidTestImplementation(ui) @@ -322,9 +320,6 @@ dependencies { networkTest { testImplementation(mockWebServer) } - databaseTest { - androidTestImplementation(roomTesting) - } test { testImplementation(junit4) testImplementation(snakeyaml) @@ -334,3 +329,7 @@ dependencies { } } } + +secrets { + defaultPropertiesFileName = "ci-overrides.properties" +} diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro index 51d441a1..582c87a5 100644 --- a/android/proguard-rules.pro +++ b/android/proguard-rules.pro @@ -46,4 +46,46 @@ -keep class androidx.fragment.app.FragmentContainerView -keep class de.gematik.ti.erp.app.common.usecase.model.** { *; } +# Realm +-keep class de.gematik.ti.erp.app.db.entities.** { *; } +-keep class de.gematik.ti.erp.app.db.LatestManualMigration { *; } +-keep class de.gematik.ti.erp.app.db.LatestManualMigration$Companion { *; } # companion is autogenerated +-keep class io.realm.** { *; } +-keep class kotlin.jvm.** { *; } + +-keep class kotlinx.serialization.json.** { *; } + +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-keep, allowobfuscation, allowoptimization class org.kodein.type.TypeReference +-keep, allowobfuscation, allowoptimization class org.kodein.type.JVMAbstractTypeToken$Companion$WrappingTest + +-keep, allowobfuscation, allowoptimization class * extends org.kodein.type.TypeReference +-keep, allowobfuscation, allowoptimization class * extends org.kodein.type.JVMAbstractTypeToken$Companion$WrappingTest + # -printusage r8/usages.txt \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/1.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/1.json deleted file mode 100644 index 08a8916a..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/1.json +++ /dev/null @@ -1,421 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "8c7f330b63a8806b1a21312943607476", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `uriPukIdpEnc` TEXT NOT NULL, `uriPukIdpSig` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uriPukIdpEnc", - "columnName": "uriPukIdpEnc", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uriPukIdpSig", - "columnName": "uriPukIdpSig", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c7f330b63a8806b1a21312943607476')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/10.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/10.json deleted file mode 100644 index 02451a3e..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/10.json +++ /dev/null @@ -1,649 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 10, - "identityHash": "c80062b228fc39442f267bd0a71f230f", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c80062b228fc39442f267bd0a71f230f')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/11.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/11.json deleted file mode 100644 index 78fd2fc1..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/11.json +++ /dev/null @@ -1,784 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 11, - "identityHash": "0cee0d7e55001024ec6690ee10d055d1", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0cee0d7e55001024ec6690ee10d055d1')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/12.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/12.json deleted file mode 100644 index a2c5d16c..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/12.json +++ /dev/null @@ -1,790 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 12, - "identityHash": "da0e52fa5c8d53cb597b6e1c73f32c1e", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da0e52fa5c8d53cb597b6e1c73f32c1e')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/13.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/13.json deleted file mode 100644 index 5b2e18bd..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/13.json +++ /dev/null @@ -1,802 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 13, - "identityHash": "111117d4173dc3ef3da0f1e767572de2", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '111117d4173dc3ef3da0f1e767572de2')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/14.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/14.json deleted file mode 100644 index d53b0a12..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/14.json +++ /dev/null @@ -1,820 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 14, - "identityHash": "ee82dbd8b4cfb2dee16752ca1496b2f8", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee82dbd8b4cfb2dee16752ca1496b2f8')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/15.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/15.json deleted file mode 100644 index d489213f..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/15.json +++ /dev/null @@ -1,820 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 15, - "identityHash": "ee82dbd8b4cfb2dee16752ca1496b2f8", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee82dbd8b4cfb2dee16752ca1496b2f8')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/16.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/16.json deleted file mode 100644 index c8d39483..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/16.json +++ /dev/null @@ -1,820 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 16, - "identityHash": "ee82dbd8b4cfb2dee16752ca1496b2f8", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee82dbd8b4cfb2dee16752ca1496b2f8')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/17.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/17.json deleted file mode 100644 index e0df8033..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/17.json +++ /dev/null @@ -1,853 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 17, - "identityHash": "bed93ada4e507b60687c6edd42cd1b01", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER, `lastAuditEventSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bed93ada4e507b60687c6edd42cd1b01')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/18.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/18.json deleted file mode 100644 index d8d39f61..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/18.json +++ /dev/null @@ -1,853 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 18, - "identityHash": "d5cd91d2c49b6ec12494645adc10e8b6", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER, `lastAuditEventSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5cd91d2c49b6ec12494645adc10e8b6')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/19.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/19.json deleted file mode 100644 index bbb74a4d..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/19.json +++ /dev/null @@ -1,859 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 19, - "identityHash": "b3b7938ce68ca496283d023b40b20958", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_idpAuthenticationDataEntity_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER, `lastAuditEventSynced` TEXT, `lastTaskSynced` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b3b7938ce68ca496283d023b40b20958')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/2.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/2.json deleted file mode 100644 index 6579a9ba..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/2.json +++ /dev/null @@ -1,483 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "8d365b875a65f863a7fbb7f4f299c775", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `uriPukIdpEnc` TEXT NOT NULL, `uriPukIdpSig` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uriPukIdpEnc", - "columnName": "uriPukIdpEnc", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uriPukIdpSig", - "columnName": "uriPukIdpSig", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8d365b875a65f863a7fbb7f4f299c775')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/20.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/20.json deleted file mode 100644 index 7a1a65ea..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/20.json +++ /dev/null @@ -1,844 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 20, - "identityHash": "5afb0017e6d5098318754bdccdf0505d", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER, `lastAuditEventSynced` TEXT, `lastTaskSynced` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5afb0017e6d5098318754bdccdf0505d')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/21.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/21.json deleted file mode 100644 index e95f1f2b..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/21.json +++ /dev/null @@ -1,844 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 21, - "identityHash": "ece8678f0f7254a80dbc659e5fd8b26c", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` INTEGER, `singleSignOnTokenValidOn` INTEGER, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` INTEGER, `lastAuditEventSynced` TEXT, `lastTaskSynced` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ece8678f0f7254a80dbc659e5fd8b26c')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/22.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/22.json deleted file mode 100644 index 3e347a46..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/22.json +++ /dev/null @@ -1,844 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 22, - "identityHash": "0d5b8df200338458974d23e2fd31b792", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` TEXT NOT NULL, `issueTimestamp` TEXT NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` TEXT, `singleSignOnTokenValidOn` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` TEXT, `lastAuditEventSynced` TEXT, `lastTaskSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insuranceNumber", - "columnName": "insuranceNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0d5b8df200338458974d23e2fd31b792')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/23.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/23.json deleted file mode 100644 index 1064ef41..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/23.json +++ /dev/null @@ -1,856 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 23, - "identityHash": "991796e5236a144944d696ec20a846fe", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` TEXT NOT NULL, `issueTimestamp` TEXT NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` TEXT, `singleSignOnTokenValidOn` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` TEXT, `lastAuditEventSynced` TEXT, `lastTaskSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insurantName", - "columnName": "insurantName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceIdentifier", - "columnName": "insuranceIdentifier", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceName", - "columnName": "insuranceName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '991796e5236a144944d696ec20a846fe')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/24.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/24.json deleted file mode 100644 index 1d52c358..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/24.json +++ /dev/null @@ -1,856 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 24, - "identityHash": "991796e5236a144944d696ec20a846fe", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` TEXT NOT NULL, `issueTimestamp` TEXT NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` TEXT, `singleSignOnTokenValidOn` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` TEXT, `lastAuditEventSynced` TEXT, `lastTaskSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insurantName", - "columnName": "insurantName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceIdentifier", - "columnName": "insuranceIdentifier", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceName", - "columnName": "insuranceName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '991796e5236a144944d696ec20a846fe')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/25.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/25.json deleted file mode 100644 index d9acdd89..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/25.json +++ /dev/null @@ -1,862 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 25, - "identityHash": "9f5833869a9aaff4fab017ce05a7b401", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` TEXT NOT NULL, `issueTimestamp` TEXT NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` TEXT, `singleSignOnTokenValidOn` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` TEXT, `lastAuditEventSynced` TEXT, `lastTaskSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insurantName", - "columnName": "insurantName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceIdentifier", - "columnName": "insuranceIdentifier", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceName", - "columnName": "insuranceName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `dataProtectionVersionAccepted` TEXT NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dataProtectionVersionAccepted", - "columnName": "dataProtectionVersionAccepted", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f5833869a9aaff4fab017ce05a7b401')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/26.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/26.json deleted file mode 100644 index 14e94f90..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/26.json +++ /dev/null @@ -1,862 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 26, - "identityHash": "9f5833869a9aaff4fab017ce05a7b401", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_tasks_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_auditEvents_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `${TABLE_NAME}` (`profileName`)" - } - ], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` TEXT NOT NULL, `issueTimestamp` TEXT NOT NULL, `externalAuthorizationIDsEndpoint` TEXT, `thirdPartyAuthorizationEndpoint` TEXT, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "externalAuthorizationIDsEndpoint", - "columnName": "externalAuthorizationIDsEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "thirdPartyAuthorizationEndpoint", - "columnName": "thirdPartyAuthorizationEndpoint", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `singleSignOnTokenExpiresOn` TEXT, `singleSignOnTokenValidOn` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenExpiresOn", - "columnName": "singleSignOnTokenExpiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenValidOn", - "columnName": "singleSignOnTokenValidOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "profileName" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [ - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL, `lastAuthenticated` TEXT, `lastAuditEventSynced` TEXT, `lastTaskSynced` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "insurantName", - "columnName": "insurantName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceIdentifier", - "columnName": "insuranceIdentifier", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "insuranceName", - "columnName": "insuranceName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastAuthenticated", - "columnName": "lastAuthenticated", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastAuditEventSynced", - "columnName": "lastAuditEventSynced", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastTaskSynced", - "columnName": "lastTaskSynced", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [ - { - "name": "index_profiles_name", - "unique": true, - "columnNames": [ - "name" - ], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `${TABLE_NAME}` (`name`)" - } - ], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `dataProtectionVersionAccepted` TEXT NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dataProtectionVersionAccepted", - "columnName": "dataProtectionVersionAccepted", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [ - { - "name": "index_communications_profileName", - "unique": false, - "columnNames": [ - "profileName" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" - }, - { - "name": "index_communications_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "taskId" - ] - }, - { - "table": "profiles", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "profileName" - ], - "referencedColumns": [ - "name" - ] - } - ] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "safetynetattestations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, `ourNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "jws", - "columnName": "jws", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ourNonce", - "columnName": "ourNonce", - "affinity": "BLOB", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "activeProfile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profileName", - "columnName": "profileName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f5833869a9aaff4fab017ce05a7b401')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/3.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/3.json deleted file mode 100644 index dc65afc1..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/3.json +++ /dev/null @@ -1,539 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "85f8948886002e3aaac87624cbb38eb1", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '85f8948886002e3aaac87624cbb38eb1')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/4.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/4.json deleted file mode 100644 index 81b41259..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/4.json +++ /dev/null @@ -1,551 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 4, - "identityHash": "d4173d8851299f834c9f066715ed08e2", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` INTEGER, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4173d8851299f834c9f066715ed08e2')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/5.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/5.json deleted file mode 100644 index d112a296..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/5.json +++ /dev/null @@ -1,551 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 5, - "identityHash": "9c5fd61107cf44280c42be58071198dd", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9c5fd61107cf44280c42be58071198dd')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/6.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/6.json deleted file mode 100644 index ee53d1f3..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/6.json +++ /dev/null @@ -1,563 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 6, - "identityHash": "bc5e206b06d9335e9f375c81d8371bc8", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc5e206b06d9335e9f375c81d8371bc8')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/7.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/7.json deleted file mode 100644 index 8b57f106..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/7.json +++ /dev/null @@ -1,569 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 7, - "identityHash": "a55d793097a3903fe9209adda1bac74e", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `zoomEnabled` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a55d793097a3903fe9209adda1bac74e')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/8.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/8.json deleted file mode 100644 index ceffcd16..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/8.json +++ /dev/null @@ -1,581 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 8, - "identityHash": "aee5015eca6bbe98138a0cdec5b11ece", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aee5015eca6bbe98138a0cdec5b11ece')" - ] - } -} \ No newline at end of file diff --git a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/9.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/9.json deleted file mode 100644 index 7cc05ad8..00000000 --- a/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/9.json +++ /dev/null @@ -1,617 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 9, - "identityHash": "5f64fec8db7282379117ff9972ec7056", - "entities": [ - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessCode", - "columnName": "accessCode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastModified", - "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "organization", - "columnName": "organization", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "medicationText", - "columnName": "medicationText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "expiresOn", - "columnName": "expiresOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "acceptUntil", - "columnName": "acceptUntil", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "authoredOn", - "columnName": "authoredOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scannedOn", - "columnName": "scannedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "scanSessionEnd", - "columnName": "scanSessionEnd", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "nrInScanSession", - "columnName": "nrInScanSession", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "scanSessionName", - "columnName": "scanSessionName", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "redeemedOn", - "columnName": "redeemedOn", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "rawKBVBundle", - "columnName": "rawKBVBundle", - "affinity": "BLOB", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "auditEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locale", - "columnName": "locale", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id", - "locale" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpConfiguration", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authorizationEndpoint", - "columnName": "authorizationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ssoEndpoint", - "columnName": "ssoEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tokenEndpoint", - "columnName": "tokenEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pairingEndpoint", - "columnName": "pairingEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationEndpoint", - "columnName": "authenticationEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpEncEndpoint", - "columnName": "pukIdpEncEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pukIdpSigEndpoint", - "columnName": "pukIdpSigEndpoint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "certificate", - "columnName": "certificate", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "expirationTimestamp", - "columnName": "expirationTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "issueTimestamp", - "columnName": "issueTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "idpAuthenticationDataEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "singleSignOnToken", - "columnName": "singleSignOnToken", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "singleSignOnTokenScope", - "columnName": "singleSignOnTokenScope", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "healthCardCertificate", - "columnName": "healthCardCertificate", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "aliasOfSecureElementEntry", - "columnName": "aliasOfSecureElementEntry", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "healthCardUsers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT, `cardAccessNumber` TEXT NOT NULL, `publicCertificate` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "cardAccessNumber", - "columnName": "cardAccessNumber", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicCertificate", - "columnName": "publicCertificate", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "settings", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authenticationMethod` TEXT NOT NULL, `authenticationFails` INTEGER NOT NULL, `zoomEnabled` INTEGER NOT NULL, `userHasAcceptedInsecureDevice` INTEGER NOT NULL, `id` INTEGER NOT NULL, `password_salt` BLOB, `password_hash` BLOB, `pharmacySearch_name` TEXT NOT NULL, `pharmacySearch_locationEnabled` INTEGER NOT NULL, `pharmacySearch_filterReady` INTEGER NOT NULL, `pharmacySearch_filterDeliveryService` INTEGER NOT NULL, `pharmacySearch_filterOnlineService` INTEGER NOT NULL, `pharmacySearch_filterOpenNow` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "authenticationMethod", - "columnName": "authenticationMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "authenticationFails", - "columnName": "authenticationFails", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "zoomEnabled", - "columnName": "zoomEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userHasAcceptedInsecureDevice", - "columnName": "userHasAcceptedInsecureDevice", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "password.salt", - "columnName": "password_salt", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "password.hash", - "columnName": "password_hash", - "affinity": "BLOB", - "notNull": false - }, - { - "fieldPath": "pharmacySearch.name", - "columnName": "pharmacySearch_name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.locationEnabled", - "columnName": "pharmacySearch_locationEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterReady", - "columnName": "pharmacySearch_filterReady", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterDeliveryService", - "columnName": "pharmacySearch_filterDeliveryService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOnlineService", - "columnName": "pharmacySearch_filterOnlineService", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pharmacySearch.filterOpenNow", - "columnName": "pharmacySearch_filterOpenNow", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "truststore", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`certList` TEXT NOT NULL, `ocspList` TEXT NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "certList", - "columnName": "certList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ocspList", - "columnName": "ocspList", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "communications", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))", - "fields": [ - { - "fieldPath": "communicationId", - "columnName": "communicationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "profile", - "columnName": "profile", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "time", - "columnName": "time", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "telematicsId", - "columnName": "telematicsId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "kbvUserId", - "columnName": "kbvUserId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "payload", - "columnName": "payload", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "consumed", - "columnName": "consumed", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "communicationId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "lowDetailEvents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", - "fields": [ - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "medicationDispense", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `text` TEXT, `type` TEXT, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "patientIdentifier", - "columnName": "patientIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uniqueIdentifier", - "columnName": "uniqueIdentifier", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "wasSubstituted", - "columnName": "wasSubstituted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "text", - "columnName": "text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "dosageInstruction", - "columnName": "dosageInstruction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "performer", - "columnName": "performer", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "whenHandedOver", - "columnName": "whenHandedOver", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "taskId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f64fec8db7282379117ff9972ec7056')" - ] - } -} diff --git a/android/src/androidTest/java/de/gematik/ti/erp/app/FullIntegrationTest.kt b/android/src/androidTest/java/de/gematik/ti/erp/app/FullIntegrationTest.kt deleted file mode 100644 index ae30a785..00000000 --- a/android/src/androidTest/java/de/gematik/ti/erp/app/FullIntegrationTest.kt +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsToggleable -import androidx.compose.ui.test.centerY -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performGesture -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.printToString -import androidx.compose.ui.test.swipeDown -import androidx.test.espresso.IdlingPolicies -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.util.concurrent.TimeUnit - -class FullIntegrationTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Before - fun setup() { - IdlingPolicies.setIdlingResourceTimeout(50, TimeUnit.SECONDS) - IdlingPolicies.setMasterPolicyTimeout(50, TimeUnit.SECONDS) - } - - fun performClickOnOnboardingNextButton() { - composeTestRule.onNodeWithTag("onboarding/next") - .assertIsDisplayed() - .assertHasClickAction() - .performClick() - } - - fun performClickOnCardWallNextButton() { - composeTestRule.onNodeWithTag("cardWall/next") - .assertIsDisplayed() - .assertHasClickAction() - .performClick() - } - - fun awaitDisplay(timeout: Long, node: () -> SemanticsNodeInteraction) { - val t0 = System.currentTimeMillis() - do { - try { - node().assertIsDisplayed() - return - } catch (_: AssertionError) { - } - composeTestRule.mainClock.advanceTimeByFrame() - Thread.sleep(100) - } while (System.currentTimeMillis() - t0 < timeout) - throw AssertionError( - "Node was not displayed after $timeout milliseconds. Root node was:\n${ - composeTestRule.onRoot().printToString(Int.MAX_VALUE) - }" - ) - } - - fun awaitDisplay(timeout: Long, vararg tags: String): String { - val t0 = System.currentTimeMillis() - do { - tags.forEach { tag -> - try { - composeTestRule.onNodeWithTag(tag).assertIsDisplayed() - return tag - } catch (_: AssertionError) { - } - } - composeTestRule.mainClock.advanceTimeByFrame() - Thread.sleep(100) - } while (System.currentTimeMillis() - t0 < timeout) - throw AssertionError( - "Node was not displayed after $timeout milliseconds. Root node was:\n${ - composeTestRule.onRoot().printToString(Int.MAX_VALUE) - }" - ) - } - - @Test - fun testEmptyMessages_showsEmptyScreen() { - - composeTestRule.mainClock.autoAdvance = true - - val foundStartupTag = awaitDisplay( - 5000L, - "onboarding/welcome", - "pull2refresh", - ) - - when (foundStartupTag) { - "onboarding/welcome" -> { - onBoarding() - prescriptionsRefresh() - cardWall() - } - "pull2refresh" -> { - prescriptionsRefresh() - cardWall() - } - } - } - - private fun onBoarding() { - composeTestRule.onNodeWithTag("onboarding/welcome") - .assertIsDisplayed() - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/features").assertIsDisplayed() - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/secureAppPage").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/secure_text_input_1").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/secure_text_input_1").performTextInput("a") - composeTestRule.onNodeWithTag("onboarding/secure_text_input_2").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/secure_text_input_2").performTextInput("a") - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/analytics").assertIsDisplayed() - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/terms").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/next") - .assertIsDisplayed() - .assertHasClickAction() - .assertIsNotEnabled() - - composeTestRule.onNodeWithTag("onb_btn_accept_privacy").assertIsDisplayed() - .assertHasClickAction() - .assertIsToggleable().performClick() - - composeTestRule.onNodeWithTag("onb_btn_accept_terms_of_use").assertIsDisplayed() - .assertHasClickAction() - .assertIsToggleable().performClick() - - performClickOnOnboardingNextButton() - } - - @OptIn(ExperimentalTestApi::class) - fun prescriptionsRefresh() { - composeTestRule.onNodeWithTag("pull2refresh") - .assertIsDisplayed() - .performGesture { - swipeDown(endY = centerY) - } - } - - fun cardWall() { - val foundCardWallTag = awaitDisplay( - 5000L, - "cardWall/intro", - "cardWall/cardAccessNumber", - "cardWall/personalIdentificationNumber" - ) - - when (foundCardWallTag) { - "cardWall/intro" -> { - performClickOnCardWallNextButton() - cardAccessNumber() - personalIdentificationNumber() - authenticationSelection() - authentication() - } - "cardWall/cardAccessNumber" -> { - cardAccessNumber() - personalIdentificationNumber() - authenticationSelection() - authentication() - } - "cardWall/personalIdentificationNumber" -> { - personalIdentificationNumber() - authentication() - } - } - } - - fun cardAccessNumber() { - composeTestRule.onNodeWithTag("cardWall/next") - .assertIsNotEnabled() - - composeTestRule.onNodeWithTag("cardWall/cardAccessNumberInputField") - .assertIsDisplayed() - .performClick() - .performTextInput("123123") - - performClickOnCardWallNextButton() - } - - fun personalIdentificationNumber() { - composeTestRule.onNodeWithTag("cardWall/next") - .assertIsNotEnabled() - - composeTestRule.onNodeWithTag("cardWall/personalIdentificationNumberInputField") - .assertIsDisplayed() - .performClick() - .performTextInput("123456") - - performClickOnCardWallNextButton() - } - - fun authenticationSelection() { - - val tag = awaitDisplay( - 5000L, - "cardWall/authenticationSelection", - "cardWall/authentication", - ) - - if (tag == "cardWall/authenticationSelection") { - composeTestRule.onNodeWithTag("cardWall/authenticationSelection").assertIsDisplayed() - - composeTestRule.onNodeWithTag("cardWall/next") - .assertIsNotEnabled() - - composeTestRule.onNodeWithTag("cardWall/authenticationSelection/healthCard") - .assertIsDisplayed() - .performClick() - - performClickOnCardWallNextButton() - } - } - - fun authentication() { - composeTestRule.onNodeWithTag("cardWall/authentication").assertIsDisplayed() - - awaitDisplay(20_000L) { - composeTestRule.onNodeWithTag("cardWall/outro") - } - - performClickOnCardWallNextButton() - } -} diff --git a/android/src/androidTest/java/de/gematik/ti/erp/app/common/OnboardingHandler.kt b/android/src/androidTest/java/de/gematik/ti/erp/app/common/OnboardingHandler.kt deleted file mode 100644 index 422cd577..00000000 --- a/android/src/androidTest/java/de/gematik/ti/erp/app/common/OnboardingHandler.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.common - -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsToggleable -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.printToString -import de.gematik.ti.erp.app.MainActivity -import org.junit.Before -import org.junit.Rule - -/** - * BaseIntegrationTest handles OnBoarding in case it is needed - */ -open class OnboardingHandler { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Before - fun handleOnBoarding() { - - if (awaitDisplay( - 5000L, - "onboarding/welcome", - "erx_btn_messages" - ) == "onboarding/welcome" - ) { - onBoarding() - } - } - - private fun onBoarding() { - composeTestRule.onNodeWithTag("onboarding/welcome") - .assertIsDisplayed() - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/features").assertIsDisplayed() - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/secureAppPage").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/secure_text_input_1").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/secure_text_input_1").performTextInput("a") - composeTestRule.onNodeWithTag("onboarding/secure_text_input_2").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/secure_text_input_2").performTextInput("a") - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/analytics").assertIsDisplayed() - - performClickOnOnboardingNextButton() - - composeTestRule.onNodeWithTag("onboarding/terms").assertIsDisplayed() - composeTestRule.onNodeWithTag("onboarding/next") - .assertIsDisplayed() - .assertHasClickAction() - .assertIsNotEnabled() - - composeTestRule.onNodeWithTag("onb_btn_accept_privacy").assertIsDisplayed() - .assertHasClickAction() - .assertIsToggleable().performClick() - - composeTestRule.onNodeWithTag("onb_btn_accept_terms_of_use").assertIsDisplayed() - .assertHasClickAction() - .assertIsToggleable().performClick() - - performClickOnOnboardingNextButton() - } - - fun performClickOnOnboardingNextButton() { - composeTestRule.onNodeWithTag("onboarding/next") - .assertIsDisplayed() - .assertHasClickAction() - .performClick() - } - - private fun awaitDisplay(timeout: Long, vararg tags: String): String { - val t0 = System.currentTimeMillis() - do { - tags.forEach { tag -> - try { - composeTestRule.onNodeWithTag(tag).assertIsDisplayed() - return tag - } catch (_: AssertionError) { - } - } - composeTestRule.mainClock.advanceTimeByFrame() - Thread.sleep(100) - } while (System.currentTimeMillis() - t0 < timeout) - throw AssertionError( - "Node was not displayed after $timeout milliseconds. Root node was:\n${ - composeTestRule.onRoot().printToString(Int.MAX_VALUE) - }" - ) - } -} diff --git a/android/src/androidTest/java/de/gematik/ti/erp/app/db/AppDatabaseMigrationTest.kt b/android/src/androidTest/java/de/gematik/ti/erp/app/db/AppDatabaseMigrationTest.kt deleted file mode 100644 index 3ae757a5..00000000 --- a/android/src/androidTest/java/de/gematik/ti/erp/app/db/AppDatabaseMigrationTest.kt +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db - -import androidx.room.Room -import androidx.room.testing.MigrationTestHelper -import androidx.sqlite.db.SupportSQLiteDatabase -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import de.gematik.ti.erp.app.db.converter.TruststoreConverter -import de.gematik.ti.erp.app.di.RoomModule -import de.gematik.ti.erp.app.di.TruststoreModule -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class AppDatabaseMigrationTest { - - private val TEST_DB = "migration-test" - - private val truststoreConverter = TruststoreConverter(TruststoreModule.provideTruststoreMoshi()) - - @get:Rule - val helper: MigrationTestHelper = MigrationTestHelper( - InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, - FrameworkSQLiteOpenHelperFactory() - ) - - // Placeholder for future migrations - @Test - fun migratesFromVersion1ToVersionX() { - helper.createDatabase(TEST_DB, 1).apply { - close() - } - Room.databaseBuilder( - InstrumentationRegistry.getInstrumentation().targetContext, - AppDatabase::class.java, - TEST_DB - ) - .addTypeConverter(truststoreConverter) - .addMigrations(*RoomModule.migrations).build().apply { - openHelper.writableDatabase - close() - } - } - - @Test - fun migratesFromVersion4ToVersion5() { - helper.createDatabase(TEST_DB, 4).apply { - execSQL( - "INSERT INTO `medicationDispense` (`taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text`, `type`)" + - "VALUES ('test1', 'test2', 'test3', 1, 'test4', 'test5', 'test6', 'test7', 123)" - ) - close() - } - - helper.runMigrationsAndValidate(TEST_DB, 5, true, MIGRATION_4_5).use { db -> - db.query("SELECT `taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text`, `type` FROM `medicationDispense`") - .let { - it.moveToFirst() - assertEquals("test1", it.getString((it.getColumnIndex("taskId")))) - assertEquals("test2", it.getString((it.getColumnIndex("patientIdentifier")))) - assertEquals("test3", it.getString((it.getColumnIndex("uniqueIdentifier")))) - assertEquals(1, it.getInt((it.getColumnIndex("wasSubstituted")))) - assertEquals("test4", it.getString((it.getColumnIndex("dosageInstruction")))) - assertEquals("test5", it.getString((it.getColumnIndex("performer")))) - assertEquals("test6", it.getString((it.getColumnIndex("whenHandedOver")))) - assertEquals("test7", it.getString((it.getColumnIndex("text")))) - assertEquals(null, it.getString((it.getColumnIndex("type")))) - } - } - } - - @Test - fun migratesFromVersion10ToVersion11() { - helper.createDatabase(TEST_DB, 10).apply { - execSQL( - "INSERT INTO `communications` (`communicationId`, `profile`, `time`, `taskId`, `telematicsId`, `kbvUserId`, `payload`, `consumed`)" + - "VALUES ('1', '', '', 'TaskId/1', '', '', '', 0)" - ) - execSQL( - "INSERT INTO `communications` (`communicationId`, `profile`, `time`, `taskId`, `telematicsId`, `kbvUserId`, `payload`, `consumed`)" + - "VALUES ('2', '', '', 'TaskId/2', '', '', '', 0)" - ) - execSQL( - "INSERT INTO `communications` (`communicationId`, `profile`, `time`, `taskId`, `telematicsId`, `kbvUserId`, `payload`, `consumed`)" + - "VALUES ('3', '', '', 'TaskId/3', '', '', '', 0)" - ) - execSQL( - """ - INSERT INTO `tasks` ( - `taskId`, - `accessCode`, - `lastModified`, - `organization`, - `medicationText`, - `expiresOn`, - `acceptUntil`, - `authoredOn`, - `status`, - `scannedOn`, - `scanSessionEnd`, - `nrInScanSession`, - `scanSessionEnd`, - `scanSessionName`, - `redeemedOn`, - `rawKBVBundle` - ) VALUES ( - 'TaskId/2', - '1', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - NULL - ) - """.trimIndent() - ) - execSQL( - """ - INSERT INTO `tasks` ( - `taskId`, - `accessCode`, - `lastModified`, - `organization`, - `medicationText`, - `expiresOn`, - `acceptUntil`, - `authoredOn`, - `status`, - `scannedOn`, - `scanSessionEnd`, - `nrInScanSession`, - `scanSessionEnd`, - `scanSessionName`, - `redeemedOn`, - `rawKBVBundle` - ) VALUES ( - 'TaskId/8', - '1', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - NULL - ) - """.trimIndent() - ) - } - - helper.runMigrationsAndValidate(TEST_DB, 11, true, MIGRATION_10_11).use { db -> - db.setForeignKeyConstraintsEnabled(true) - db.assertForeignKeyConstraints("communications") - db.assertForeignKeyConstraints("tasks") - assertEquals(1, db.query("SELECT * FROM `communications`").count) - assertEquals(2, db.query("SELECT * FROM `tasks`").count) - db.query("SELECT taskId FROM `communications`").let { - it.moveToFirst() - assertEquals("TaskId/2", it.getString(0)) - } - db.query("SELECT taskId FROM `tasks`").let { - it.moveToFirst() - assertEquals("TaskId/2", it.getString(0)) - it.moveToNext() - assertEquals("TaskId/8", it.getString(0)) - } - } - } - - @Test - fun migratesFromVersion15ToVersion16() { - helper.createDatabase(TEST_DB, 15).apply { - execSQL( - "INSERT INTO `tasks` (`taskId`, `profileName`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionEnd`, `scanSessionName`, `redeemedOn`, `rawKBVBundle`)" + - "VALUES ('1', 'Test', '1', '', '', '', '', '', '', 'Wrong status', '', '', '', '', '', '', NULL)" - ) - execSQL( - "INSERT INTO `tasks` (`taskId`, `profileName`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionEnd`, `scanSessionName`, `redeemedOn`, `rawKBVBundle`)" + - "VALUES ('2', 'Test', '2', '', '', '', '', '', '', NULL, '', '', '', '', '', '', NULL)" - ) - close() - } - - helper.runMigrationsAndValidate(TEST_DB, 16, true, MIGRATION_15_16).use { db -> - db.query("SELECT `status` FROM `tasks`") - .let { - it.moveToFirst() - assertEquals("Other", it.getString((it.getColumnIndex("status")))) - it.moveToNext() - assertEquals(null, it.getString((it.getColumnIndex("status")))) - } - } - } -} - -fun SupportSQLiteDatabase.assertForeignKeyConstraints(table: String) { - assertEquals(0, query("PRAGMA foreign_key_check(`$table`);").count) -} diff --git a/android/src/androidTest/java/de/gematik/ti/erp/app/messages/ui/MainScreenIntegrationTest.kt b/android/src/androidTest/java/de/gematik/ti/erp/app/messages/ui/MainScreenIntegrationTest.kt deleted file mode 100644 index 7ccf397a..00000000 --- a/android/src/androidTest/java/de/gematik/ti/erp/app/messages/ui/MainScreenIntegrationTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import de.gematik.ti.erp.app.common.OnboardingHandler -import org.junit.Test - -@ExperimentalTestApi -class MainScreenIntegrationTest : OnboardingHandler() { - - @Test - fun testBottomBar_navigationOptions() { - composeTestRule.onNodeWithTag("erx_btn_prescriptions").assertIsDisplayed() - composeTestRule.onNodeWithTag("erx_btn_messages").assertIsDisplayed() - composeTestRule.onNodeWithTag("erx_btn_search_pharmacies").assertIsDisplayed() - } - - @Test - fun testClickBottomBar_clicksMessages() { - - composeTestRule.onNodeWithTag("erx_btn_messages") - .assertIsDisplayed() - .performClick() - composeTestRule.onNodeWithTag("message_screen").assertIsDisplayed() - } - - @Test - fun testClickBottomBar_clicksPrescriptions() { - - composeTestRule.onNodeWithTag("erx_btn_prescriptions") - .assertIsDisplayed() - .performClick() - composeTestRule.onNodeWithTag("main_screen").assertIsDisplayed() - } - - @Test - fun testClickBottomBar_clicksPharmacy() { - - composeTestRule.onNodeWithTag("erx_btn_search_pharmacies") - .assertIsDisplayed() - .performClick() - // not working yet -// composeTestRule.onNodeWithTag("pharmacy_search_screen").assertIsDisplayed() - } - - @Test - fun testClickSettings() { - - composeTestRule.onNodeWithTag("erx_btn_show_settings") - .assertIsDisplayed() - .performClick() - composeTestRule.onNodeWithTag("settings_screen").assertIsDisplayed() - } - -// @Test -// fun testLoadingPrescriptions() { -// composeTestRule.onNodeWithTag("pull2refresh") -// .assertIsDisplayed() -// .performGesture { -// swipeDown(endY = centerY) -// } -// } -// -// @After -// fun tearDown() { -// mockWebServer.shutdown() -// } -} diff --git a/android/src/androidTest/java/de/gematik/ti/erp/app/messages/ui/MessageComponentsTest.kt b/android/src/androidTest/java/de/gematik/ti/erp/app/messages/ui/MessageComponentsTest.kt deleted file mode 100644 index 97a3b24c..00000000 --- a/android/src/androidTest/java/de/gematik/ti/erp/app/messages/ui/MessageComponentsTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui - -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.navigation.testing.TestNavHostController -import androidx.test.core.app.ApplicationProvider -import de.gematik.ti.erp.app.DefaultDispatchProvider -import de.gematik.ti.erp.app.MainActivity -import de.gematik.ti.erp.app.messages.testErrorUIMessage -import de.gematik.ti.erp.app.messages.testUIMessage -import de.gematik.ti.erp.app.messages.ui.models.UIMessage -import de.gematik.ti.erp.app.messages.usecase.MessageUseCase -import de.gematik.ti.erp.app.theme.AppTheme -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flow -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -@ExperimentalMaterialApi -class MessageComponentsTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - private lateinit var viewModel: MessageViewModel - private lateinit var useCase: MessageUseCase - private lateinit var navController: TestNavHostController - - @Before - fun setup() { - useCase = mockk() - viewModel = MessageViewModel(useCase, DefaultDispatchProvider()) - navController = TestNavHostController( - ApplicationProvider.getApplicationContext() - ) - } - - @Test - fun testEmptyMessages_showsEmptyScreen() { - every { useCase.loadCommunicationsLocally(any()) } returns flow { emit(listOf()) } - composeTestRule.setContent { - AppTheme { - MessageScreen(navController, viewModel) - } - } - composeTestRule.onNodeWithText("Keine Mitteilungen").assertIsDisplayed() - composeTestRule.onNodeWithText("Sie haben", substring = true).assertIsDisplayed() - } - - @Test - fun testNonEmptyMessages_showsMessages() { - every { useCase.loadCommunicationsLocally(any()) } returns - flow { - emit( - listOf( - testUIMessage() - ) - ) - } - - composeTestRule.setContent { - AppTheme { - MessageScreen(navController, viewModel) - } - } - composeTestRule.onNodeWithTag("lazyColumn").assertIsDisplayed() - } - - @Test - fun testNonEmptyMessages_showErrorMessage() { - every { useCase.loadCommunicationsLocally(any()) } returns - flow { - emit( - listOf( - testUIMessage(), - testErrorUIMessage() - ) - ) - } - - composeTestRule.setContent { - AppTheme { - MessageScreen(navController, viewModel) - } - } - composeTestRule.onNodeWithTag("lazyColumn").assertIsDisplayed() - composeTestRule.onNodeWithText("Fehlerhafte", substring = true).assertIsDisplayed() - } -} diff --git a/android/src/androidTest/java/de/gematik/ti/erp/app/vau/TruststoreDatabaseTest.kt b/android/src/androidTest/java/de/gematik/ti/erp/app/vau/TruststoreDatabaseTest.kt deleted file mode 100644 index be50c208..00000000 --- a/android/src/androidTest/java/de/gematik/ti/erp/app/vau/TruststoreDatabaseTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau - -import android.content.Context -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.converter.TruststoreConverter -import de.gematik.ti.erp.app.db.daos.TruststoreDao -import de.gematik.ti.erp.app.db.entities.TruststoreEntity -import de.gematik.ti.erp.app.vau.api.model.OCSPAdapter -import de.gematik.ti.erp.app.vau.api.model.X509Adapter -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -class TruststoreDatabaseTest { - - private lateinit var truststoreDao: TruststoreDao - private lateinit var db: AppDatabase - - private val moshi = Moshi.Builder().add(OCSPAdapter()).add(X509Adapter()).build() - - @Before - fun createDB() { - val context = ApplicationProvider.getApplicationContext() - db = Room.inMemoryDatabaseBuilder( - context, AppDatabase::class.java - ) - .addTypeConverter(TruststoreConverter(moshi)) - .build() - truststoreDao = db.truststoreDao() - } - - @After - fun closeDB() { - db.close() - } - - @Test - fun trustStoreSavesBothLists() = runBlocking { - assertEquals(null, truststoreDao.getUntrusted()) - - val entity = TruststoreEntity( - TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList - ) - - truststoreDao.insert(entity) - - assertEquals(entity, truststoreDao.getUntrusted()) - - truststoreDao.deleteAll() - - assertEquals(null, truststoreDao.getUntrusted()) - } -} diff --git a/android/src/debug/AndroidManifest.xml b/android/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..91ae69c1 --- /dev/null +++ b/android/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/data/DebugSettingsData.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/data/DebugSettingsData.kt index fb9789ab..a42cea63 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/data/DebugSettingsData.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/data/DebugSettingsData.kt @@ -20,6 +20,7 @@ package de.gematik.ti.erp.app.debug.data import android.os.Parcelable import androidx.compose.runtime.Immutable +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.parcelize.Parcelize @Immutable @@ -29,11 +30,18 @@ data class DebugSettingsData( val eRezeptActive: Boolean, val idpUrl: String, val idpActive: Boolean, + val pharmacyServiceUrl: String, + val pharmacyServiceActive: Boolean, val bearerToken: String, val bearerTokenIsSet: Boolean, val fakeNFCCapabilities: Boolean, val cardAccessNumberIsSet: Boolean, - val cardWallIntroIsAccepted: Boolean, val multiProfile: Boolean, - val activeProfileName: String + val activeProfileId: ProfileIdentifier, + val virtualHealthCardCert: String, + val virtualHealthCardPrivateKey: String ) : Parcelable + +enum class Environment { + PU, TU, RU, DEVRU, TR +} diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugLoadingButton.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugLoadingButton.kt new file mode 100644 index 00000000..6c9b3236 --- /dev/null +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugLoadingButton.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.debug.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.AlertDialog +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import kotlinx.coroutines.launch + +@Composable +fun DebugLoadingButton( + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String, + onClick: suspend () -> Unit +) { + var loading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + errorMessage?.let { error -> + AlertDialog( + onDismissRequest = { + errorMessage = null + }, + buttons = { + Button(onClick = { errorMessage = null }) { + Text("OK") + } + }, + text = { + Text(error) + } + ) + } + val scope = rememberCoroutineScope() + + Button( + modifier = modifier.fillMaxWidth(), + onClick = { + loading = true + scope.launch { + try { + onClick() + } catch (e: Exception) { + errorMessage = e.message + (e.cause?.message?.let { " - cause: $it" } ?: "") + } finally { + loading = false + } + } + }, + enabled = enabled && !loading + ) { + if (loading) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = AppTheme.colors.neutral600) + SpacerSmall() + } + Text(text, textAlign = TextAlign.Center) + } +} diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt index 6dad401d..7bda2feb 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreen.kt @@ -18,55 +18,143 @@ package de.gematik.ti.erp.app.debug.ui -import android.security.NetworkSecurityPolicy -import androidx.compose.foundation.BorderStroke +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.RadioButton import androidx.compose.material.Switch import androidx.compose.material.Text import androidx.compose.material.TextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.debug.data.Environment +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AlertDialog +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer24 +import de.gematik.ti.erp.app.utils.compose.OutlinedDebugButton import de.gematik.ti.erp.app.utils.compose.SpacerMedium -import java.net.URI -import kotlinx.coroutines.flow.collect +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.navigationModeState import kotlinx.coroutines.launch +import org.bouncycastle.util.encoders.Base64 +import org.kodein.di.bindProvider +import org.kodein.di.compose.rememberViewModel +import org.kodein.di.compose.subDI +import org.kodein.di.instance +import java.io.ByteArrayOutputStream +import java.time.LocalDateTime +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.math.max + +@Composable +private fun DebugCard( + modifier: Modifier = Modifier, + title: String, + onReset: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) = + Card( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + backgroundColor = AppTheme.colors.neutral100, + elevation = 0.dp, + border = null + ) { + Box { + Column( + Modifier.padding(PaddingDefaults.Medium), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + Text( + title, + style = MaterialTheme.typography.h6, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + SpacerMedium() + content() + } + onReset?.run { + IconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(PaddingDefaults.Small), + onClick = onReset + ) { + Icon(Icons.Rounded.Refresh, null) + } + } + } + } @Composable fun EditablePathComponentSetButton( - modifier: Modifier, + modifier: Modifier = Modifier, label: String, text: String, active: Boolean, onValueChange: (String, Boolean) -> Unit, onClick: () -> Unit ) { - val color = if (active) Color.Green else Color.Red val buttonText = if (active) "SAVED" else "SET" EditablePathComponentWithControl( @@ -89,11 +177,10 @@ fun EditablePathComponentSetButton( @Composable fun TextWithResetButtonComponent( - modifier: Modifier, + modifier: Modifier = Modifier, label: String, onClick: () -> Unit, active: Boolean - ) { val color = if (active) Color.Green else Color.Red Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { @@ -101,7 +188,7 @@ fun TextWithResetButtonComponent( text = label, modifier = Modifier .weight(1f) - .padding(end = 16.dp) + .padding(end = PaddingDefaults.Medium) ) val text = if (active) "UNSET" else "RESET" Button( @@ -116,7 +203,7 @@ fun TextWithResetButtonComponent( @Composable fun EditablePathComponentCheckable( - modifier: Modifier, + modifier: Modifier = Modifier, label: String, textFieldValue: String, checked: Boolean, @@ -144,9 +231,7 @@ fun EditablePathComponentWithControl( onValueChange: (String, Boolean) -> Unit, content: @Composable ((Boolean) -> Unit) -> Unit ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { - TextField( value = textFieldValue, onValueChange = { onValueChange(it, false) }, @@ -154,7 +239,7 @@ fun EditablePathComponentWithControl( maxLines = 3, modifier = Modifier .weight(1f) - .padding(end = 16.dp) + .padding(end = PaddingDefaults.Medium) ) content { onValueChange(textFieldValue, it) } @@ -162,204 +247,456 @@ fun EditablePathComponentWithControl( } @Composable -fun DebugScreen(navigation: NavController, viewModel: DebugSettingsViewModel = hiltViewModel()) { - val header = "Debug Settings" - - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - header - ) { navigation.popBackStack() } +fun DebugScreen( + settingsNavController: NavController +) { + val navController = rememberNavController() + val navMode by navController.navigationModeState(DebugScreenNavigation.DebugMain.path()) + + subDI(diBuilder = { + bindProvider { + DebugSettingsViewModel( + visibleDebugTree = instance(), + endpointHelper = instance(), + cardWallUseCase = instance(), + prescriptionUseCase = instance(), + vauRepository = instance(), + idpRepository = instance(), + idpUseCase = instance(), + profilesUseCase = instance(), + featureToggleManager = instance(), + pharmacyDirectRedeemUseCase = instance(), + dispatchers = instance() + ) } - ) { innerPadding -> - - LaunchedEffect(key1 = Unit) { - viewModel.state() + }) { + NavHost( + navController, + startDestination = DebugScreenNavigation.DebugMain.path() + ) { + composable(DebugScreenNavigation.DebugMain.route) { + NavigationAnimation(mode = navMode) { + DebugScreenMain( + onBack = { + settingsNavController.popBackStack() + }, + onClickDirectRedemption = { + navController.navigate(DebugScreenNavigation.DebugRedeemWithoutFD.path()) + } + ) + } + } + composable(DebugScreenNavigation.DebugRedeemWithoutFD.route) { + NavigationAnimation(mode = navMode) { + DebugScreenDirectRedeem( + onBack = { + navController.popBackStack() + } + ) + } + } } + } +} - val elementDistance = 16.dp - val modifier = Modifier.padding( - start = elementDistance, - end = elementDistance, - bottom = elementDistance - ) - - Column( +@Composable +fun DebugScreenDirectRedeem(onBack: () -> Unit) { + val viewModel by rememberViewModel() + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + navigationMode = NavigationBarMode.Back, + listState = listState, + topBarTitle = "Debug Redeem", + onBack = onBack + ) { innerPadding -> + var shipmentUrl by remember { mutableStateOf("") } + var deliveryUrl by remember { mutableStateOf("") } + var onPremiseUrl by remember { mutableStateOf("") } + var message by remember { mutableStateOf("") } + var certificates by remember { mutableStateOf("") } + + LazyColumn( + state = listState, modifier = Modifier .padding(innerPadding) - .verticalScroll(rememberScrollState()), + .navigationBarsPadding() + .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + contentPadding = PaddingValues(PaddingDefaults.Medium) ) { - Spacer(modifier = Modifier.height(elementDistance)) - - Button( - onClick = { viewModel.restartWithOnboarding() }, - modifier = modifier.fillMaxWidth() - ) { - Text(text = "Neustart mit aktiviertem Onboarding") + item { + DebugCard( + title = "Endpoints" + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = shipmentUrl, + label = { Text("Shipment URL") }, + onValueChange = { + shipmentUrl = it + } + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = deliveryUrl, + label = { Text("Delivery URL") }, + onValueChange = { + deliveryUrl = it + } + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = onPremiseUrl, + label = { Text("OnPremise URL") }, + onValueChange = { + onPremiseUrl = it + } + ) + } } - - Button( - onClick = { viewModel.refreshPrescriptions() }, - modifier = modifier.fillMaxWidth() - ) { - Text(text = "Trigger prescription refresh") + item { + RedeemButton( + viewModel = viewModel, + url = shipmentUrl, + message = message, + certificates = certificates, + text = "Send as Shipment" + ) + RedeemButton( + viewModel = viewModel, + url = deliveryUrl, + message = message, + certificates = certificates, + text = "Send as Delivery" + ) + RedeemButton( + viewModel = viewModel, + url = onPremiseUrl, + message = message, + certificates = certificates, + text = "Send as OnPremise" + ) } - - Card( - modifier = Modifier.padding(horizontal = 2.dp), - shape = RoundedCornerShape(8.dp), - elevation = 2.dp, - border = BorderStroke(1.dp, Color.LightGray) - ) { - Column { - - Spacer(modifier = modifier) - - Text( - text = "Card Wall", modifier = modifier, style = MaterialTheme.typography.h6 + item { + DebugCard( + title = "Message" + ) { + OutlinedTextField( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(), + value = message, + label = { Text("Any Message") }, + onValueChange = { + message = it + } ) - - TextWithResetButtonComponent( - modifier = modifier, - label = "Card Wall Intro", - onClick = { viewModel.resetCardWallIntro() }, - active = !viewModel.debugSettingsData.cardWallIntroIsAccepted + } + } + item { + DebugCard( + title = "Certificates" + ) { + OutlinedTextField( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(), + value = certificates, + label = { Text("Certificate as PEM") }, + onValueChange = { + certificates = it + } ) - val scope = rememberCoroutineScope() + } + } + } + } +} - TextWithResetButtonComponent( - modifier = modifier, - label = "Card Access Number", - onClick = { - scope.launch { - viewModel.resetCardAccessNumber() - } - }, - active = !viewModel.debugSettingsData.cardAccessNumberIsSet - ) +@Composable +private fun RedeemButton( + viewModel: DebugSettingsViewModel, + url: String, + message: String, + certificates: String, + text: String +) = + DebugLoadingButton( + onClick = { viewModel.redeemDirect(url = url, message = message, certificatesPEM = certificates) }, + enabled = url.isNotEmpty() && certificates.isNotEmpty(), + text = text + ) - Row(modifier = modifier.fillMaxWidth()) { - Text( - text = "So tun als hätte das Handy NFC ", - modifier = Modifier - .weight(1f) - .padding(end = 16.dp) +@Suppress("LongMethod") +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DebugScreenMain( + onBack: () -> Unit, + onClickDirectRedemption: () -> Unit +) { + val viewModel by rememberViewModel() + val listState = rememberLazyListState() + val modal = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + + ModalBottomSheetLayout( + sheetContent = { + EnvironmentSelector( + currentSelectedEnvironment = viewModel.getCurrentEnvironment(), + onSelectEnvironment = { viewModel.selectEnvironment(it) } + ) { + scope.launch { viewModel.saveAndRestartApp() } + } + }, + sheetState = modal + ) { + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.DebugMenu.DebugMenuScreen), + navigationMode = NavigationBarMode.Close, + listState = listState, + topBarTitle = "Debug Settings", + onBack = onBack + ) { innerPadding -> + + LaunchedEffect(Unit) { + viewModel.state() + } + LazyColumn( + state = listState, + modifier = Modifier + .padding(innerPadding) + .navigationBarsPadding() + .testTag(TestTag.DebugMenu.DebugMenuContent), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + item { + DebugCard( + title = "General" + ) { + Button( + onClick = onClickDirectRedemption, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Direct Redemption") + } + Button( + onClick = { viewModel.refreshPrescriptions() }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Trigger Prescription Refresh") + } + } + } + item { + DebugCard( + title = "Card Wall" + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Fake NFC Capability", + modifier = Modifier + .weight(1f) + ) + Switch( + checked = viewModel.debugSettingsData.fakeNFCCapabilities, + onCheckedChange = { viewModel.allowNfc(it) } + ) + } + } + } + item { + DebugCard( + title = "Authentication" + ) { + EditablePathComponentSetButton( + label = "Bearer Token", + text = viewModel.debugSettingsData.bearerToken, + active = viewModel.debugSettingsData.bearerTokenIsSet, + onValueChange = { text, _ -> + viewModel.updateState( + viewModel.debugSettingsData.copy( + bearerToken = text, + bearerTokenIsSet = false + ) + ) + }, + onClick = { + viewModel.changeBearerToken(viewModel.debugSettingsData.activeProfileId) + } ) - Switch( - checked = viewModel.debugSettingsData.fakeNFCCapabilities, - onCheckedChange = { viewModel.allowNfc(it) } + Button( + onClick = { scope.launch { viewModel.breakSSOToken() } }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Break SSO Token") + } + } + } + item { + DebugCard(title = "Environment") { + OutlinedDebugButton( + modifier = Modifier.fillMaxWidth(), + text = "Select Environment", + onClick = { scope.launch { modal.show() } } ) } } + item { + VirtualHealthCard(viewModel = viewModel) + } + item { + FeatureToggles(viewModel = viewModel) + } + item { + RotatingLog(viewModel = viewModel) + } } + } + } +} - Spacer(modifier = modifier) +private const val maxNumberOfVisualLogs = 25 - TextWithResetButtonComponent( - modifier = modifier, - label = "UI Hints", - onClick = { viewModel.resetHints() }, - active = false - ) +@Composable +private fun RotatingLog(modifier: Modifier = Modifier, viewModel: DebugSettingsViewModel) { + DebugCard(modifier, title = "Log") { + val logs by viewModel.rotatingLog.collectAsState(emptyList()) + val joinedLog = + logs.subList(max(0, logs.size - maxNumberOfVisualLogs), logs.size).fold(AnnotatedString("")) { acc, log -> + acc + AnnotatedString("\n") + log + } - Spacer(modifier = modifier) - - EditablePathComponentSetButton( - modifier = modifier, - label = "Bearer Token", - text = viewModel.debugSettingsData.bearerToken, - active = viewModel.debugSettingsData.bearerTokenIsSet, - onValueChange = { text, _ -> - viewModel.updateState( - viewModel.debugSettingsData.copy( - bearerToken = text, - bearerTokenIsSet = false - ) - ) - }, - onClick = { - viewModel.changeBearerToken(viewModel.debugSettingsData.activeProfileName) + var text by remember(joinedLog) { mutableStateOf(TextFieldValue(joinedLog)) } + + Row { + val clipboard = LocalClipboardManager.current + Button(onClick = { clipboard.setText(joinedLog) }) { + Text("Copy All") + } + + Spacer(Modifier.weight(1f)) + + val context = LocalContext.current + val mailAddress = stringResource(R.string.settings_contact_mail_address) + Button(onClick = { + val intent = Intent(Intent.ACTION_SENDTO) + intent.data = Uri.parse("mailto:") + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(mailAddress)) + intent.putExtra(Intent.EXTRA_SUBJECT, "#Log-#Android-${LocalDateTime.now()}") + + val bout = ByteArrayOutputStream() + ZipOutputStream(bout).use { + val e = ZipEntry("log.txt") + it.putNextEntry(e) + + val data = joinedLog.text.toByteArray() + it.write(data, 0, data.size) + it.closeEntry() } - ) - Spacer(modifier = modifier) + intent.putExtra(Intent.EXTRA_TEXT, Base64.toBase64String(bout.toByteArray())) - Button( - onClick = { viewModel.breakSSOToken() }, - modifier = modifier.fillMaxWidth() - ) { - Text(text = "Break SSO Token") + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } + }) { + Text("Send Mail") } + } - Card( - modifier = Modifier.padding(horizontal = 2.dp), - shape = RoundedCornerShape(8.dp), - elevation = 2.dp, - border = BorderStroke(1.dp, Color.LightGray) - ) { - Column { + OutlinedTextField( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(), + value = text, + readOnly = true, + onValueChange = { + text = it + } + ) + } +} - Spacer(modifier = modifier) +@Composable +private fun VirtualHealthCard(modifier: Modifier = Modifier, viewModel: DebugSettingsViewModel) { + var virtualHealthCardLoading by remember { mutableStateOf(false) } + var virtualHealthCardError by remember { mutableStateOf(null) } + virtualHealthCardError?.let { error -> + AlertDialog( + onDismissRequest = { + virtualHealthCardError = null + }, + buttons = { + Button(onClick = { virtualHealthCardError = null }) { + Text("OK") + } + }, + text = { + Text(error) + } + ) + } - Text( - text = "Service URLs", - modifier = modifier, - style = MaterialTheme.typography.h6 - ) + DebugCard(modifier, title = "Virtual Health Card", onReset = viewModel::onResetVirtualHealthCard) { + val scope = rememberCoroutineScope() - val clearTextAllowed = - !NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted - Text( - text = "Ist Cleartext Traffic erlaubt: $clearTextAllowed", - modifier = modifier.align(Alignment.Start) - ) + OutlinedTextField( + modifier = Modifier + .testTag(TestTag.DebugMenu.CertificateField) + .heightIn(max = 144.dp) + .fillMaxWidth(), + value = viewModel.debugSettingsData.virtualHealthCardCert, + onValueChange = { + viewModel.onSetVirtualHealthCardCertificate(it) + }, + label = { Text("Certificate in Base64") } + ) - EditablePathComponentCheckable( - modifier = modifier, - label = "ERezept Fachdienst Base URL", - textFieldValue = viewModel.debugSettingsData.eRezeptServiceURL, - checked = viewModel.debugSettingsData.eRezeptActive, - onValueChange = { text, checked -> - runCatching { URI(text) }.getOrNull()?.run { - viewModel.updateState( - viewModel.debugSettingsData.copy( - eRezeptServiceURL = text, - eRezeptActive = checked - ) - ) - } - } - ) - EditablePathComponentCheckable( - modifier = modifier, - label = "IDP Service Base URL", - textFieldValue = viewModel.debugSettingsData.idpUrl, - checked = viewModel.debugSettingsData.idpActive, - onValueChange = { text, checked -> - runCatching { URI(text) }.getOrNull()?.run { - viewModel.updateState( - viewModel.debugSettingsData.copy( - idpUrl = text, - idpActive = checked - ) - ) - } - } - ) + val subjectInfo = + remember(viewModel.debugSettingsData.virtualHealthCardCert) { viewModel.getVirtualHealthCardCertificateSubjectInfo() } + Text(subjectInfo, style = AppTheme.typography.caption1l) - Button( - onClick = { viewModel.saveAndRestartApp() }, - modifier = modifier.fillMaxWidth() - ) { - Text(text = "Speichern und Neustarten") + OutlinedTextField( + modifier = Modifier + .testTag(TestTag.DebugMenu.PrivateKeyField) + .heightIn(max = 144.dp) + .fillMaxWidth(), + value = viewModel.debugSettingsData.virtualHealthCardPrivateKey, + onValueChange = { + viewModel.onSetVirtualHealthCardPrivateKey(it) + }, + label = { Text("Private Key in Base64") } + ) + + Button( + modifier = Modifier.fillMaxWidth().testTag(TestTag.DebugMenu.SetVirtualHealthCardButton), + onClick = { + virtualHealthCardLoading = true + scope.launch { + try { + viewModel.onTriggerVirtualHealthCard( + certificateBase64 = viewModel.debugSettingsData.virtualHealthCardCert, + privateKeyBase64 = viewModel.debugSettingsData.virtualHealthCardPrivateKey + ) + } catch (e: Exception) { + virtualHealthCardError = e.message + } finally { + virtualHealthCardLoading = false } } + }, + enabled = !virtualHealthCardLoading + ) { + if (virtualHealthCardLoading) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = AppTheme.colors.neutral600) + SpacerSmall() } - SpacerMedium() - FeatureToggles(viewModel = viewModel) + Text("Set Virtual Health Card for Active Profile", textAlign = TextAlign.Center) } } } @@ -371,40 +708,77 @@ private fun FeatureToggles(modifier: Modifier = Modifier, viewModel: DebugSettin value = it } } - Card( - modifier = Modifier.padding(horizontal = 2.dp), - shape = RoundedCornerShape(8.dp), - elevation = 2.dp, - border = BorderStroke(1.dp, Color.LightGray) + DebugCard(modifier, title = "Feature Toggles") { + for (feature in viewModel.features()) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = feature.featureName, + modifier = Modifier + .weight(1f), + style = MaterialTheme.typography.body1 + ) + Switch( + checked = featuresState[feature.featureName] ?: false, + onCheckedChange = { viewModel.toggleFeature(feature) } + ) + } + } + } +} + +@Composable +fun EnvironmentSelector( + currentSelectedEnvironment: Environment, + onSelectEnvironment: (environment: Environment) -> Unit, + onSaveEnvironment: () -> Unit +) { + var selectedEnvironment by remember { mutableStateOf(currentSelectedEnvironment) } + + Column( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .selectableGroup() ) { - Column { - Spacer(modifier = modifier) - Text( - text = "Feature toggles", - modifier = modifier.padding(16.dp), - style = MaterialTheme.typography.h6 - ) + Text( + text = stringResource(R.string.debug_select_environment), + style = AppTheme.typography.h6, + modifier = Modifier.padding(PaddingDefaults.Medium) + ) - for (feature in viewModel.features()) { + Environment.values().forEach { + Row( + modifier = Modifier.fillMaxWidth().clickable { + selectedEnvironment = it + onSelectEnvironment(it) + } + ) { Row( - modifier = modifier - .fillMaxWidth() - .padding(16.dp) + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.Small), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = feature.featureName, - modifier = Modifier - .weight(1f) - .padding(end = 16.dp) - - ) - Switch( - checked = featuresState[feature.featureName] ?: false, - onCheckedChange = { viewModel.toggleFeature(feature) } + RadioButton( + modifier = Modifier.size(32.dp), + selected = selectedEnvironment == it, + onClick = { + selectedEnvironment = it + onSelectEnvironment(it) + } ) + Text(it.name) } } } + Row(modifier = Modifier.padding(PaddingDefaults.Medium)) { + Button(modifier = Modifier.fillMaxWidth(), onClick = { onSaveEnvironment() }) { + Text(text = stringResource(R.string.debug_save_environment)) + } + Spacer(modifier = Modifier.navigationBarsPadding()) + } } - Spacer24() } diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/ActiveProfile.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenNavigation.kt similarity index 75% rename from android/src/main/java/de/gematik/ti/erp/app/db/entities/ActiveProfile.kt rename to android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenNavigation.kt index 7cef90aa..681118bf 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/ActiveProfile.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenNavigation.kt @@ -16,14 +16,11 @@ * */ -package de.gematik.ti.erp.app.db.entities +package de.gematik.ti.erp.app.debug.ui -import androidx.room.Entity -import androidx.room.PrimaryKey +import de.gematik.ti.erp.app.Route -@Entity(tableName = "activeProfile") -data class ActiveProfile( - @PrimaryKey - val id: Int = 0, - val profileName: String, -) +object DebugScreenNavigation { + object DebugMain : Route("DebugMain") + object DebugRedeemWithoutFD : Route("DebugRedeemWithoutFD") +} diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenWrapper.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenWrapper.kt index 667411de..38837607 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenWrapper.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugScreenWrapper.kt @@ -19,9 +19,8 @@ package de.gematik.ti.erp.app.debug.ui import androidx.compose.runtime.Composable -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @Composable -fun DebugScreenWrapper(navigation: NavController, viewModel: DebugSettingsViewModel = hiltViewModel()) = - DebugScreen(navigation = navigation, viewModel = viewModel) +fun DebugScreenWrapper(navigation: NavController) = + DebugScreen(settingsNavController = navigation) diff --git a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt index 17c69638..4959d583 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/debug/ui/DebugSettingsViewModel.kt @@ -19,122 +19,205 @@ package de.gematik.ti.erp.app.debug.ui import android.content.Intent -import android.content.SharedPreferences +import android.content.pm.PackageManager import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.jakewharton.processphoenix.ProcessPhoenix -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.App -import de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.BCProvider +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.VisibleDebugTree import de.gematik.ti.erp.app.cardwall.usecase.CardWallUseCase -import de.gematik.ti.erp.app.common.usecase.HintUseCase -import de.gematik.ti.erp.app.core.BaseViewModel import de.gematik.ti.erp.app.debug.data.DebugSettingsData -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.di.ApplicationPreferences +import de.gematik.ti.erp.app.debug.data.Environment import de.gematik.ti.erp.app.di.EndpointHelper import de.gematik.ti.erp.app.featuretoggle.FeatureToggleManager import de.gematik.ti.erp.app.featuretoggle.Features +import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyDirectRedeemUseCase import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.settings.ui.NEW_USER import de.gematik.ti.erp.app.vau.repository.VauRepository -import javax.inject.Inject +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.util.encoders.Base64 +import org.bouncycastle.util.io.pem.PemReader +import org.jose4j.base64url.Base64Url +import org.jose4j.jws.EcdsaUsingShaAlgorithm +import java.math.BigInteger +import java.security.KeyFactory +import java.security.Signature +import java.time.Instant +import java.time.temporal.ChronoUnit -const val DEBUG_SETTINGS_STATE = "DEBUG_SETTINGS_STATE" +private val HealthCardCert = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE +private val HealthCardCertPrivateKey = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY -@HiltViewModel -class DebugSettingsViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, +@Suppress("LongParameterList") +class DebugSettingsViewModel( + visibleDebugTree: VisibleDebugTree, private val endpointHelper: EndpointHelper, - @ApplicationPreferences - private val sharedPreferences: SharedPreferences, private val cardWallUseCase: CardWallUseCase, - private val hintUseCase: HintUseCase, - private val demoUseCase: DemoUseCase, private val prescriptionUseCase: PrescriptionUseCase, private val vauRepository: VauRepository, private val idpRepository: IdpRepository, + private val idpUseCase: IdpUseCase, private val profilesUseCase: ProfilesUseCase, - private val featureToggleManager: FeatureToggleManager -) : BaseViewModel() { + private val featureToggleManager: FeatureToggleManager, + private val pharmacyDirectRedeemUseCase: PharmacyDirectRedeemUseCase, + private val dispatchers: DispatchProvider +) : ViewModel() { - var debugSettingsData by mutableStateOf( - savedStateHandle.get(DEBUG_SETTINGS_STATE) ?: createDebugSettingsData() - ) + var debugSettingsData by mutableStateOf(createDebugSettingsData()) + + val rotatingLog = visibleDebugTree.rotatingLog private fun createDebugSettingsData() = DebugSettingsData( - endpointHelper.eRezeptServiceUri, - endpointHelper.isUriOverridden(EndpointHelper.EndpointUri.BASE_SERVICE_URI), - endpointHelper.idpServiceUri, - endpointHelper.isUriOverridden(EndpointHelper.EndpointUri.IDP_SERVICE_URI), - "", - true, - cardWallUseCase.deviceHasNFCAndAndroidMOrHigher, - false, - cardWallUseCase.cardWallIntroIsAccepted, - false, - activeProfileName = "" + eRezeptServiceURL = endpointHelper.eRezeptServiceUri, + eRezeptActive = endpointHelper.isUriOverridden(EndpointHelper.EndpointUri.BASE_SERVICE_URI), + idpUrl = endpointHelper.idpServiceUri, + idpActive = endpointHelper.isUriOverridden(EndpointHelper.EndpointUri.IDP_SERVICE_URI), + pharmacyServiceUrl = endpointHelper.pharmacySearchBaseUri, + pharmacyServiceActive = endpointHelper.isUriOverridden(EndpointHelper.EndpointUri.PHARMACY_SERVICE_URI), + bearerToken = "", + bearerTokenIsSet = true, + fakeNFCCapabilities = cardWallUseCase.deviceHasNFCAndAndroidMOrHigher, + cardAccessNumberIsSet = false, + multiProfile = false, + activeProfileId = "", + virtualHealthCardCert = HealthCardCert, + virtualHealthCardPrivateKey = HealthCardCertPrivateKey ) suspend fun state() { - val it = profilesUseCase.activeProfileName().first() + val it = profilesUseCase.activeProfileId().first() updateState( debugSettingsData.copy( - cardAccessNumberIsSet = cardWallUseCase.cardAccessNumberWasSaved().first(), - activeProfileName = it, - bearerToken = idpRepository.decryptedAccessTokenMap.value[it] ?: "" + cardAccessNumberIsSet = ( + cardWallUseCase.authenticationData(it) + .first().singleSignOnTokenScope as? IdpData.TokenWithHealthCardScope + )?.cardAccessNumber?.isNotEmpty() + ?: false, + activeProfileId = it, + bearerToken = idpRepository.decryptedAccessToken(it).first() ?: "" ) ) } fun updateState(debugSettingsData: DebugSettingsData) { this.debugSettingsData = debugSettingsData - savedStateHandle[DEBUG_SETTINGS_STATE] = debugSettingsData } - fun restartWithOnboarding() { - sharedPreferences.edit().putBoolean(NEW_USER, true).commit() - restart() + fun selectEnvironment(environment: Environment) { + updateState(getDebugSettingsdataForEnvironment(environment)) } - fun changeBearerToken(activeProfileName: String) { - idpRepository.decryptedAccessTokenMap.update { - it + (activeProfileName to debugSettingsData.bearerToken) + private fun getDebugSettingsdataForEnvironment(environment: Environment): DebugSettingsData { + return when (environment) { + Environment.PU -> debugSettingsData.copy( + eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_PU, + eRezeptActive = true, + idpUrl = BuildKonfig.IDP_SERVICE_URI_PU, + idpActive = true, + pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_PU, + pharmacyServiceActive = true + ) + Environment.TU -> debugSettingsData.copy( + eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_TU, + eRezeptActive = true, + idpUrl = BuildKonfig.IDP_SERVICE_URI_TU, + idpActive = true, + pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_RU, + pharmacyServiceActive = true + ) + Environment.RU -> debugSettingsData.copy( + eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_RU, + eRezeptActive = true, + idpUrl = BuildKonfig.IDP_SERVICE_URI_RU, + idpActive = true, + pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_RU, + pharmacyServiceActive = true + ) + Environment.DEVRU -> debugSettingsData.copy( + eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_RU_DEV, + eRezeptActive = true, + idpUrl = BuildKonfig.IDP_SERVICE_URI_RU_DEV, + idpActive = true, + pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_RU, + pharmacyServiceActive = true + ) + Environment.TR -> debugSettingsData.copy( + eRezeptServiceURL = BuildKonfig.BASE_SERVICE_URI_TR, + eRezeptActive = true, + idpUrl = BuildKonfig.IDP_SERVICE_URI_TR, + idpActive = true, + pharmacyServiceUrl = BuildKonfig.PHARMACY_SERVICE_URI_RU, + pharmacyServiceActive = true + ) } + } + + fun changeBearerToken(activeProfileId: ProfileIdentifier) { + idpRepository.saveDecryptedAccessToken(activeProfileId, debugSettingsData.bearerToken) updateState(debugSettingsData.copy(bearerTokenIsSet = true)) } - fun breakSSOToken() = runBlocking { - val activeProfileName = profilesUseCase.activeProfileName().first() - idpRepository.getSingleSignOnToken(activeProfileName).first()?.let { - val newToken = when (it) { - is SingleSignOnToken.AlternateAuthenticationToken -> - it.copy(token = it.token.removeRange(0..2)) - is SingleSignOnToken.AlternateAuthenticationWithoutToken -> - it - is SingleSignOnToken.DefaultToken -> - it.copy(token = it.token.removeRange(0..2)) + suspend fun breakSSOToken() { + withContext(dispatchers.IO) { + val activeProfileId = profilesUseCase.activeProfileId().first() + idpRepository.authenticationData(activeProfileId).first().singleSignOnTokenScope?.let { + val newToken = when (it) { + is IdpData.AlternateAuthenticationToken -> + IdpData.AlternateAuthenticationToken( + token = it.token?.breakToken(), + cardAccessNumber = it.cardAccessNumber, + aliasOfSecureElementEntry = it.aliasOfSecureElementEntry, + healthCardCertificate = it.healthCardCertificate.encoded + ) + is IdpData.DefaultToken -> + IdpData.DefaultToken( + token = it.token?.breakToken(), + cardAccessNumber = it.cardAccessNumber, + healthCardCertificate = it.healthCardCertificate.encoded + ) + is IdpData.ExternalAuthenticationToken -> + IdpData.ExternalAuthenticationToken( + token = it.token?.breakToken(), + authenticatorName = it.authenticatorName, + authenticatorId = it.authenticatorId + ) + else -> it + } + idpRepository.saveSingleSignOnToken( + activeProfileId, + newToken + ) + Napier.d("SSO token is now: $newToken", tag = "Debug Settings") } - - idpRepository.setSingleSignOnToken( - activeProfileName, - newToken - ) } } - fun saveAndRestartApp() { + private fun IdpData.SingleSignOnToken.breakToken(): IdpData.SingleSignOnToken { + val (_, rest) = this.token.split('.', limit = 2) + val someHoursBeforeNow = Instant.now().minus(48, ChronoUnit.HOURS).epochSecond + val headerWithExpiresOn = Base64Url.encodeUtf8ByteRepresentation("""{"exp":$someHoursBeforeNow}""") + return IdpData.SingleSignOnToken("$headerWithExpiresOn.$rest") + } + + suspend fun saveAndRestartApp() { endpointHelper.setUriOverride( EndpointHelper.EndpointUri.BASE_SERVICE_URI, debugSettingsData.eRezeptServiceURL, @@ -145,28 +228,19 @@ class DebugSettingsViewModel @Inject constructor( debugSettingsData.idpUrl, debugSettingsData.idpActive ) - - viewModelScope.launch { - idpRepository.invalidateWithUserCredentials(profilesUseCase.activeProfileName().first()) - vauRepository.invalidate() + endpointHelper.setUriOverride( + EndpointHelper.EndpointUri.PHARMACY_SERVICE_URI, + debugSettingsData.pharmacyServiceUrl, + debugSettingsData.pharmacyServiceActive + ) + profilesUseCase.profiles.flowOn(Dispatchers.IO).first().forEach { + idpRepository.invalidate(it.id) } - + vauRepository.invalidate() restart() } - suspend fun resetCardAccessNumber() { - cardWallUseCase.setCardAccessNumber(null) - updateState(debugSettingsData.copy(cardAccessNumberIsSet = false)) - } - - fun resetCardWallIntro() { - cardWallUseCase.cardWallIntroIsAccepted = false - updateState(debugSettingsData.copy(cardWallIntroIsAccepted = false)) - } - - fun resetHints() { - hintUseCase.resetAllHints() - } + fun getCurrentEnvironment() = endpointHelper.getCurrentEnvironment() fun allowNfc(value: Boolean) { cardWallUseCase.deviceHasNFCAndAndroidMOrHigher = value @@ -174,11 +248,8 @@ class DebugSettingsViewModel @Inject constructor( } fun refreshPrescriptions() { - if (demoUseCase.isDemoModeActive) { - demoUseCase.authTokenReceived.value = true - } viewModelScope.launch { - prescriptionUseCase.downloadTasks(profilesUseCase.activeProfileName().first()) + prescriptionUseCase.downloadTasks(profilesUseCase.activeProfileId().first()) } } @@ -194,13 +265,82 @@ class DebugSettingsViewModel @Inject constructor( } } - fun activeProfileName() = - profilesUseCase.activeProfileName() - private fun restart() { - Thread.sleep(500) - ProcessPhoenix.triggerRebirth( - App.appContext, Intent(App.appContext, MainActivity::class.java) + val context = App.appContext + val packageManager: PackageManager = context.packageManager + val intent = packageManager.getLaunchIntentForPackage(context.packageName) + val componentName = intent!!.component + val mainIntent = Intent.makeRestartActivityTask(componentName) + context.startActivity(mainIntent) + Runtime.getRuntime().exit(0) + } + + fun onResetVirtualHealthCard() { + updateState( + debugSettingsData.copy( + virtualHealthCardCert = HealthCardCert, + virtualHealthCardPrivateKey = HealthCardCertPrivateKey + ) ) } + + fun onSetVirtualHealthCardCertificate(cert: String) { + updateState(debugSettingsData.copy(virtualHealthCardCert = cert)) + } + + fun onSetVirtualHealthCardPrivateKey(privateKey: String) { + updateState(debugSettingsData.copy(virtualHealthCardPrivateKey = privateKey)) + } + + fun getVirtualHealthCardCertificateSubjectInfo(): String = + try { + X509CertificateHolder(Base64.decode(debugSettingsData.virtualHealthCardCert)).subject.toString() + } catch (e: Exception) { + e.message ?: "Error" + } + + suspend fun onTriggerVirtualHealthCard( + certificateBase64: String, + privateKeyBase64: String + ) = withContext(dispatchers.IO) { + idpUseCase.authenticationFlowWithHealthCard( + profileId = profilesUseCase.activeProfileId().first(), + cardAccessNumber = "123123", + healthCardCertificate = { Base64.decode(certificateBase64) }, + sign = { + val curveSpec = ECNamedCurveTable.getParameterSpec("brainpoolP256r1") + val keySpec = + org.bouncycastle.jce.spec.ECPrivateKeySpec(BigInteger(Base64.decode(privateKeyBase64)), curveSpec) + val privateKey = KeyFactory.getInstance("EC", BCProvider).generatePrivate(keySpec) + val signed = Signature.getInstance("NoneWithECDSA").apply { + initSign(privateKey) + update(it) + }.sign() + EcdsaUsingShaAlgorithm.convertDerToConcatenated(signed, 64) + } + ) + } + + suspend fun redeemDirect( + url: String, + message: String, + certificatesPEM: String + ) { + val pemReader = PemReader(certificatesPEM.reader()) + + val certificates = mutableListOf() + do { + val obj = pemReader.readPemObject() + if (obj != null) { + certificates += X509CertificateHolder(obj.content) + } + } while (obj != null) + + pharmacyDirectRedeemUseCase.redeemPrescription( + url = url, + message = message, + telematikId = "", + recipientCertificates = certificates + ).getOrThrow() + } } diff --git a/android/src/debug/java/de/gematik/ti/erp/app/di/EndpointHelper.kt b/android/src/debug/java/de/gematik/ti/erp/app/di/EndpointHelper.kt new file mode 100644 index 00000000..ea055194 --- /dev/null +++ b/android/src/debug/java/de/gematik/ti/erp/app/di/EndpointHelper.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.di + +import android.content.SharedPreferences +import androidx.core.content.edit +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.debug.data.Environment + +class EndpointHelper( + private val networkPrefs: SharedPreferences +) { + + enum class EndpointUri(val original: String, val preferenceKey: String) { + BASE_SERVICE_URI( + BuildKonfig.BASE_SERVICE_URI, + "BASE_SERVICE_URI_OVERRIDE" + ), + IDP_SERVICE_URI( + BuildKonfig.IDP_SERVICE_URI, + "IDP_SERVICE_URI_OVERRIDE" + ), + PHARMACY_SERVICE_URI( + BuildKonfig.PHARMACY_SERVICE_URI, + "PHARMACY_BASE_URI_OVERRIDE" + ) + } + + val eRezeptServiceUri + get() = getUriForEndpoint(EndpointUri.BASE_SERVICE_URI) + + val idpServiceUri + get() = getUriForEndpoint(EndpointUri.IDP_SERVICE_URI) + + val pharmacySearchBaseUri + get() = getUriForEndpoint(EndpointUri.PHARMACY_SERVICE_URI) + + private fun getUriForEndpoint(uri: EndpointUri): String { + var url = uri.original + if (isUriOverridden(uri)) { + url = networkPrefs.getString( + uri.preferenceKey, + uri.original + )!! + } + if (url.last() != '/') { + url += '/' + } + return url + } + + private fun overrideSwitchKey(uri: EndpointUri): String { + return uri.preferenceKey + "_ACTIVE" + } + + fun isUriOverridden(uri: EndpointUri): Boolean { + return networkPrefs.getBoolean(overrideSwitchKey(uri), false) + } + + fun setUriOverride(uri: EndpointUri, debugUri: String, active: Boolean) { + networkPrefs.edit(commit = true) { + putBoolean(overrideSwitchKey(uri), active) + putString(uri.preferenceKey, debugUri) + } + } + + fun getCurrentEnvironment(): Environment { + return when { + eRezeptServiceUri == BuildKonfig.BASE_SERVICE_URI_PU && + idpServiceUri == BuildKonfig.IDP_SERVICE_URI_PU && + pharmacySearchBaseUri == BuildKonfig.PHARMACY_SERVICE_URI_PU -> { + Environment.PU + } + eRezeptServiceUri == BuildKonfig.BASE_SERVICE_URI_RU && + idpServiceUri == BuildKonfig.IDP_SERVICE_URI_RU && + pharmacySearchBaseUri == BuildKonfig.PHARMACY_SERVICE_URI_RU -> { + Environment.RU + } + eRezeptServiceUri == BuildKonfig.BASE_SERVICE_URI_TU && + idpServiceUri == BuildKonfig.IDP_SERVICE_URI_TU && + pharmacySearchBaseUri == BuildKonfig.PHARMACY_SERVICE_URI_RU -> { + Environment.TU + } + eRezeptServiceUri == BuildKonfig.BASE_SERVICE_URI_TR && + idpServiceUri == BuildKonfig.IDP_SERVICE_URI_TR && + pharmacySearchBaseUri == BuildKonfig.PHARMACY_SERVICE_URI_RU -> { + Environment.TR + } + eRezeptServiceUri == BuildKonfig.BASE_SERVICE_URI_RU_DEV && + idpServiceUri == BuildKonfig.IDP_SERVICE_URI_RU_DEV && + pharmacySearchBaseUri == BuildKonfig.PHARMACY_SERVICE_URI_RU -> { + Environment.DEVRU + } + else -> { + return Environment.PU + } + } + } + + fun getErpApiKey(): String { + return if (BuildKonfig.INTERNAL) { + when (getCurrentEnvironment()) { + Environment.PU -> BuildKonfig.ERP_API_KEY_GOOGLE_PU + Environment.TU -> BuildKonfig.ERP_API_KEY_GOOGLE_TU + Environment.RU, + Environment.DEVRU -> BuildKonfig.ERP_API_KEY_GOOGLE_RU + Environment.TR -> BuildKonfig.ERP_API_KEY_GOOGLE_TR + } + } else { + BuildKonfig.ERP_API_KEY + } + } + + fun getPharmacyApiKey(): String { + return if (BuildKonfig.INTERNAL) { + when (getCurrentEnvironment()) { + Environment.PU -> BuildKonfig.PHARMACY_API_KEY_PU + else -> BuildKonfig.PHARMACY_API_KEY_RU + } + } else { + BuildKonfig.PHARMACY_API_KEY + } + } + + fun getTrustAnchor(): String { + return if (BuildKonfig.INTERNAL) { + when (getCurrentEnvironment()) { + Environment.PU -> BuildKonfig.APP_TRUST_ANCHOR_BASE64_PU + else -> BuildKonfig.APP_TRUST_ANCHOR_BASE64_TU + } + } else { + BuildKonfig.APP_TRUST_ANCHOR_BASE64 + } + } +} diff --git a/android/src/debug/java/de/gematik/ti/erp/app/utils/compose/DebugCommon.kt b/android/src/debug/java/de/gematik/ti/erp/app/utils/compose/DebugCommon.kt index b2ef7978..8e51adb4 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/utils/compose/DebugCommon.kt +++ b/android/src/debug/java/de/gematik/ti/erp/app/utils/compose/DebugCommon.kt @@ -18,7 +18,13 @@ package de.gematik.ti.erp.app.utils.compose +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.OutlinedButton @@ -26,10 +32,29 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BugReport import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import java.util.UUID @Composable fun OutlinedDebugButton( @@ -42,7 +67,7 @@ fun OutlinedDebugButton( border = ButtonDefaults.outlinedBorder.copy(brush = SolidColor(AppTheme.DebugColor)), colors = ButtonDefaults.textButtonColors(contentColor = AppTheme.DebugColor), contentPadding = PaddingValues(horizontal = PaddingDefaults.Small, vertical = PaddingDefaults.Tiny), - modifier = modifier + modifier = modifier.testTag(TestTag.Onboarding.SkipOnboardingButton) ) { Icon(Icons.Outlined.BugReport, null) SpacerSmall() @@ -50,3 +75,57 @@ fun OutlinedDebugButton( SpacerTiny() } } + +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.visualTestTag(tag: String) = + composed(fullyQualifiedName = "de.gematik.ti.erp.app.utils.compose.visualTestTag", key1 = tag) { + val activity = LocalContext.current as MainActivity + val uuid = remember { UUID.randomUUID().toString() } + + DisposableEffect(tag) { + onDispose { + activity.elements -= uuid + } + } + + Modifier + .testTag(tag) + .onGloballyPositioned { + activity.elements += uuid to MainActivity.Element(it.boundsInRoot(), tag) + } + } + +@Composable +fun DebugOverlay(elements: Map) { + Box(Modifier.fillMaxSize()) { + elements.entries.forEachIndexed { index, (key, el) -> + key(key) { + Box( + Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure( + Constraints.fixed( + el.bounds.width.toInt(), + el.bounds.height.toInt() + ) + ) + layout(placeable.width, placeable.height) { + placeable.place(el.bounds.topLeft.round()) + } + } + .border(width = 2.dp, color = Color.Magenta, shape = RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + ) { + Text( + text = el.tag, + color = Color.Magenta, + overflow = TextOverflow.Visible, + modifier = Modifier + .background(Color.White.copy(alpha = 0.5f)) + .padding(start = 4.dp, end = 2.dp) + ) + } + } + } + } +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index fb3d3c2d..42ecd870 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,87 +1,86 @@ + xmlns:tools="http://schemas.android.com/tools"> - + - + - - + + - - - - - - + + + + + + + + android:name="android.hardware.camera" + android:required="false"/> - + android:allowBackup="false" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_label" + android:networkSecurityConfig="@xml/network_security_config" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.ERezApp" + tools:targetApi="n"> + + android:name="com.google.mlkit.common.internal.MlKitInitProvider" + android:authorities="${applicationId}.mlkitinitprovider" + tools:node="remove"/> + + + android:name=".MainActivity" + android:exported="true" + android:screenOrientation="portrait" + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize"> - + - + - + - + - - + + + android:pathPrefix="/extauth" + android:host="das-e-rezept-fuer-deutschland.de" + android:scheme="https"/> + - - - - - - - - - - diff --git a/android/src/main/assets/data_terms.html b/android/src/main/assets/data_terms.html index 2b6456a1..3bfcbf77 100644 --- a/android/src/main/assets/data_terms.html +++ b/android/src/main/assets/data_terms.html @@ -1,13 +1,14 @@ - + 2022-01-06 Datenschutzerklaerung-E-Rezept-App + - +

Datenschutzerklärung

-

(Stand Januar 2022)

+

(Stand März 2022)

In dieser Datenschutzerklärung erfahren Sie, wie Ihre Daten bei Nutzung dieser App verarbeitet werden und welche Datenschutzrechte Sie haben. Die Datenschutzerklärung richtet sich an alle Nutzer dieser App.

@@ -25,7 +26,14 @@

Inhalt

  • Kann ich meine elektronische Gesundheitskarte verwenden?
  • Wie kann ich meine E-Rezepte verwalten?
  • Wie kann ich meine E-Rezepte einlösen?
  • +
  • Was passiert mit den Kontaktdaten und der Lieferadresse?
  • +
  • Was passiert, wenn ich mich anmelde / mit einem Backend verbinde?
  • Kann ich über die App Nachrichten senden?
  • +
  • Muss ich jedes Mal die Gesundheitskarte zur Anmeldung verwenden?
  • +
  • Wofür sind Profile da?
  • +
  • Wie kann (und darf) ich ein Profil anlegen, um Rezepte einer anderen Person zu erhalten?
  • +
  • Wenn ich jemandem erlaube, für mich Rezepte zu erhalten, was für Daten kann diese Person einsehen?
  • +
  • Wie kann ich meine Erlaubnis, für mich Rezepte zu erhalten, wieder entziehen?
  • Kann ich mir Apotheken merken?
  • Werden meine Daten analysiert?
  • Welche Daten werden bei der Nutzung der Kartendienste in der App erhoben?
  • @@ -57,7 +65,6 @@

    Inhalt

  • Was wir Ihnen ermöglichen
  • Betroffenenrechte
  • - @@ -73,8 +80,9 @@

    Wer ist fü

    https://www.gematik.de/hilfe-kontakt/kontaktformular/

    -

    mit der Zuordnung des Anfragethemas „Datenschutz“. -Um die App installieren zu können, müssen Sie ggf. zuvor bei einem Appstoreanbieter (z.B. Apple, Google) eine Nutzungsvereinbarung über den Zugang zu dem jeweiligen Appstore abschließen. Die gematik ist nicht Vertragspartner dieser Vereinbarung und hat keinen Einfluss auf die Datenverarbeitung bei dem Appstoreanbieter.

    +

    mit der Zuordnung des Anfragethemas „Datenschutz“.

    + +

    Um die App installieren zu können, müssen Sie ggf. zuvor bei einem Appstoreanbieter (z.B. Apple, Google) eine Nutzungsvereinbarung über den Zugang zu dem jeweiligen Appstore abschließen. Die gematik ist nicht Vertragspartner dieser Vereinbarung und hat keinen Einfluss auf die Datenverarbeitung bei dem Appstoreanbieter.

    Wie funktioniert das E-Rezept?

    @@ -117,9 +125,9 @@

    Was passiert, wenn Sie d

    Kann ich meine elektronische Gesundheitskarte verwenden?

    -

    Nach dem Starten der App können Sie sich mit Ihrer elektronischen Gesundheitskarte ("eGK") anmelden. Im Rahmen der Anmeldung erheben wir zunächst Ihre 6-stellige Zugangsnummer. Nach Eingabe Ihres PINs wird die eGK an Ihrem Endgerät eingelesen.

    +

    Nach dem Starten der App können Sie sich mit Ihrer elektronischen Gesundheitskarte ("eGK") anmelden. Im Rahmen der Anmeldung erheben wir zunächst Ihre 6-stellige Zugangsnummer. Nach Eingabe Ihres PINs wird die eGK an Ihrem Endgerät eingelesen.

    -

    Sie haben die Möglichkeit, dass die E-Rezept-App Ihre persönlichen Daten der eGK lokal auf Ihrem Endgerät speichert, damit Sie diese nicht bei jedem neuen Start der App neu eingeben müssen. Dazu gehören Namen, Krankenversicherungsnummer und die Zugangsnummer (Card Access Number, "CAN"). Die auf Ihrer eGK hinterlegten Zertifikate und weitergehenden Informationen werden nicht auf Ihre Endgeräte oder an uns übertragen.

    +

    Sie haben die Möglichkeit, dass die E-Rezept-App Ihre persönlichen Daten der eGK lokal auf Ihrem Endgerät speichert, damit Sie diese nicht bei jedem neuen Start der App neu eingeben müssen. Dazu gehören Namen, Krankenversicherungsnummer und die Zugangsnummer (Card Access Number, "CAN"). Die auf Ihrer eGK hinterlegten Zertifikate und weitergehenden Informationen werden nicht auf Ihre Endgeräte oder an uns übertragen.

    Wie kann ich meine E-Rezepte verwalten?

    @@ -149,13 +157,21 @@

    Wie kann ich meine E-Rezepte einl

    Damit Sie eine Apotheke in Ihrer Nähe leichter finden können, benutzt die App auch Ihre Standortdaten, wenn Sie dem zustimmen. Zur Durchführung der Suche nach einer Apotheke wird Ihr Standort von uns erhoben und an den Dienst „Apotheken-Verzeichnis“ übertragen und die Apotheken im Umkreis gesucht. Dabei wird Ihre Position durch diesen Dienst ausschließlich für diese Standort-Abfrage verwendet. Damit wir Ihnen die Nutzung des Dienstes ermöglichen können, verarbeiten wir auch hierfür Ihre IP-Adresse.

    -

    Darüber hinaus haben Sie die Möglichkeit, im Rahmen der Bestellung bei einer Apotheke freiwillig Ihre Rufnummer und eine von dem E-Rezept abweichende Lieferadresse an die Apotheke weiterzugeben. Diese Daten werden bei einer Übermittlung des E-Rezeptes über den Fachdienst an die Apotheke übermittelt.

    +

    Was passiert mit den Kontaktdaten und der Lieferadresse?

    +

    Für manche Services ist die Angabe von Kontaktdaten erforderlich, um die Lieferung sicherzustellen oder um Sie in wichtigen Situationen zu kontaktieren, zum Beispiel, wenn Sie ein anderes Medikament erhalten, als der Arzt verschrieben hat, und sich die Einnahme verändert.

    +

    Die E-Rezept App verlangt die Eingabe von Kontaktdaten und Lieferadressen, wenn dies für die Erfüllung Ihres Belieferungswunsches erforderlich ist. Wenn diese Daten nicht erforderlich sind, haben Sie dennoch die Möglichkeit, freiwillig Kontaktdaten anzugeben, oder eine alternative Lieferadresse anzugeben. Ob die Eingabe erforderlich ist, oder nicht, wird Ihnen durch die E-Rezept App angezeigt und erklärt.

    +

    Die E-Rezept App übermittelt diese Daten an die beauftragte Apotheke. Der Datentransport erfolgt dabei verschlüsselt. Die gematik hat keine Kenntnis von den Klartextinhalten der Kommunikationsinhalte.

    +

    Kontaktdaten und Lieferadressen werden von der E-Rezept App für zukünftige Bestellungen gespeichert. Die Speicherung erfolgt nur auf Ihrem Endgerät. Die Daten werden verschlüsselt gespeichert.

    -

    Diese Daten können Sie für zukünftige Bestellungen speichern, Die Speicherung erfolgt nur auf Ihrem Endgerät. Die Daten werden verschlüsselt.

    +

    Was passiert, wenn ich mich anmelde / mit einem Backend verbinde?

    +

    Wir führen bei jeder Kommunikation mit einem Backend des E-Rezept Systems eine Integritätsprüfung der App selbst durch. Es ist technisch möglich, eine veränderte Version der E-Rezept App herzustellen. Wir wollen Sie als individuellen Nutzer vor potenziellem Missbrauch durch eine gefälschte E-Rezept App zu schützen. Die Integritätsprüfung dient aber auch den Schutz unseres Rezeptdienstes, da so sichergestellt ist, dass er nur von berechtigten Anwendungen genutzt wird, und kein Missbrauch erfolgt. Sollten Sie eine fehlerhafte E-Rezept App installiert haben, so werden Sie informiert, und von der Kommunikation mit dem Rezeptdienst ausgeschlossen.

    +

    Für diese Integritätsprüfung nutzen wir Google SafetyNet. Um die Integrität zu prüfen, erhebt Google SafetyNet Informationen über das Gerät und das installierte Betriebssystem, und leitet diese zur Integritätsprüfung an eigene Server.

    +

    Die Verarbeitung Ihrer Informationen wird nicht nur von Google Ireland Limited, sondern kann auch von Google LLC in den USA durchgeführt werden. Weiterführendes unter 7. Übermittlung in Drittländer.

    Kann ich über die App Nachrichten senden?

    -

    Die E-Rezept-App ermöglicht eine direkte Kommunikation zwischen Ihnen und Apotheken. Wenn Sie ein E-Rezept an eine Apotheke übermitteln, werden Zusatzinformationen zur Botenlieferung oder Versand übertragen. Dies ist die Lieferadresse, die der Adresse auf Ihrer eGK entspricht. Zusätzlich wird beim Botendienst auch die von Ihnen einzugebende Telefonnummer übertragen. Die Apotheke wiederum kann Ihnen Informationen zusenden, aber auch eine URL zustellen, die Sie aus der E-Rezept-App heraus öffnen können.

    +

    Die E-Rezept-App ermöglicht eine direkte Kommunikation zwischen Ihnen und Apotheken. Eine solche Kommunikation ist z.B. die Übermittlung von Kontaktdaten und der Lieferadresse.

    +

    Die Apotheke wiederum kann Ihnen Informationen zusenden, aber auch eine URL zustellen, die Sie aus der E-Rezept-App heraus öffnen können.

    Die Kommunikation läuft über die Telematikinfrastruktur. Dazu ruft die E-Rezept-App die Kommunikationsdaten aus der Telematikinfrastruktur ab, und speichert diese lokal auf Ihrem Endgerät. Sie können diese Kommunikation in der E-Rezept-App jederzeit einsehen.

    @@ -163,6 +179,34 @@

    Kann ich über die App Nachric

    Zur Übertragung und Darstellung der Kommunikationsinhalte verarbeitet die gematik die von Ihnen eingegebenen Nachrichten in den Freitextfeldern, ggf. die eingefügte Lieferadressen und Rufnummern, sowie die vollständigen Verordnungen, auf die sich die Nachrichten beziehen.

    +

    Muss ich jedes Mal die Gesundheitskarte zur Anmeldung verwenden?

    +

    Nein. Bei bestimmten Telefonen können Sie die Anmeldung für einen längeren Zeitraum aufrechterhalten. Das funktioniert bei den meisten iPhone Modellen, sowie bei einigen Android Modellen. Grundlage ist das Vorhandensein eines Sicherheitsmerkmals, der s.g. „Strongbox“. Dies ist ein besonders sicherer Chip auf dem Telefon, der ein hohen Maß an Sicherheit garantiert.

    +

    Wenn Ihr Telefon die Anforderungen erfüllt, zeigt Ihnen die E-Rezept App an, dass Sie die „Zugangsdaten merken“ können.

    +

    Sie können diese Funktion auf mehreren Geräten ausführen. Damit Sie einen Überblick haben, welche Geräte alle Zugriff auf Ihre Rezeptdaten haben, zeigt Ihnen die E-Rezept App an, auf welchen Geräten Sie die Zugangsdaten gemerkt haben.

    +

    Wenn Sie die Zugangsdaten nicht mehr merken möchten, sondern wieder bei jeder Anmeldung die Gesundheitskarte nutzen möchten, können Sie dies in der E-Rezept App einstellen.

    +

    Wenn Sie anderen Geräten die Zugangsdaten entziehen möchten, können Sie dies auch in der E-Rezept App tun. Um diese Funktion ausführen zu können, müssen Sie sich möglicherweise mit der Gesundheitskarte authentifizieren.

    +

    Wenn Sie die Funktion „Zugangsdaten merken“ nutzen, dann wird auf Ihrem Smartphone ein Token verschlüsselt gespeichert. Dieses Token ist dem Authentifizierungsdienst des E-Rezept Systems (IDP - Identity-Provider) bekannt. Zusätzlich zu dem Token speichert der Authentifizierungsdienst auch die Gerätekennung und die Betriebssystemversion.

    +

    Wenn bestimmte Geräte oder Betriebssystemversionen als vulnerabel erkannt werden, werden betroffene Token vom Authentifizierungsdienst gelöscht. Sie werden durch die E-Rezept App dann aufgefordert, die eGK und PIN erneut zu nutzen.

    + +

    Wofür sind Profile da?

    +

    Mit Hilfe der Profile-Funktion können Sie z.B. für jedes Ihrer Familienmitglieder ein Profil anlegen, und diesem Profil die Gesundheitskarte des jeweiligen Familienmitglieds. Sie können dann mit der E-Rezept App für jedes Profil die betreffenden E-Rezepte laden und verwalten. Sie übernehmen damit die Rolle eines Bevollmächtigten.

    +

    Sie sind verantwortlich dafür, dass die Personen, deren Authentifizierungsmittel Sie hinterlegen, dieser Bevollmächtigung zugestimmt haben. Ferner sind Sie verantwortlich, auf Verlangen der Vollmacht-gebenden, die Vollmacht wieder aufzugeben. Sie können dies in der E-Rezept App durch Löschen des betroffenen Profiles durchführen.

    +

    Alle Profile und die hinterlegten Authentifizierungsmittel werden ausschließlich auf Ihrem Smartphone gespeichert.

    + +

    Wie kann (und darf) ich ein Profil anlegen, um Rezepte einer anderen Person zu erhalten?

    +

    Sie können jederzeit ein Profil anlegen, Die Funktion erreichen Sie über verschiedene Einstiegspunkte in der E-Rezept App. Sie können auch jederzeit Rezepte anderer Personen „fotografieren“ und in der E-Rezept App speichern.

    +

    Wenn Sie einem Profil ein Authentifizierungsmittel hinterlegen, z.B. die Gesundheitskarte, so muss die betroffene versicherte Person Sie dazu bevollmächtigt haben.

    +

    Alle Profile und die hinterlegten Authentifizierungsmittel werden ausschließlich auf Ihrem Smartphone gespeichert.

    + +

    Wenn ich jemandem erlaube, für mich Rezepte zu erhalten, was für Daten kann diese Person einsehen?

    +

    Eine bevollmächtigte Person kann alle Daten einsehen, die Sie auch einsehen könnten. Sie handelt an Ihrer statt. Der Rezeptdienst unterscheidet nicht, ob Sie oder die durch Sie bevollmächtigte Person Handlungen vornimmt.

    + +

    Wie kann ich meine Erlaubnis, für mich Rezepte zu erhalten, wieder entziehen?

    +

    Wenn Sie eine andere Person bevollmächtigt haben, an Ihrer statt auf den Rezeptdienst zuzugreifen, und hierzu die Gesundheitskarte und PIN ausgehändigt haben, so müssen Sie zunächst die Gesundheitskarte zurückfordern, oder nötigenfalls durch Ihre Krankenkasse sperren lassen.

    +

    Die E-Rezept App ermöglicht es, die Zugangsdaten zum Rezeptdienst zu speichern. Um sicherzustellen, dass die ehemals bevollmächtigte Person keinen Zugang mehr hat, öffnen Sie bitte in der E-Rezept App die Übersicht aller Geräte, die „gemerkte Zugangsdaten“ haben. Sie finden diese Funktion im Menü. Sie können hier jedem Gerät die Zugangsdaten wieder entziehen. Und somit auch der ehemals bevollmächtigten Person, sofern diese auf ihrem Gerät die „Zugangsdaten gemerkt“ hat.

    +

    Die ehemals bevollmächtigte Person kann nach Entzug der Gesundheitskarte und der „Zugangsdaten merken“-Berechtigung nicht mehr auf Ihre Daten zugreifen, die auf dem Rezeptdienst liegen. Das heißt, die ehemals bevollmächtigte Person erhält keine neuen Rezepte, keine Statusänderung und keinen Zugriff auf Protokolldaten mehr.

    +

    Daten, die die ehemals bevollmächtigte Person vor Entzug der Bevollmächtigung einsehen konnte, bleiben auch weiterhin einsehbar, da diese Daten lokal auf dem Smartphone gespeichert werden.

    +

    Kann ich mir Apotheken merken?

    Sie können innerhalb der E-Rezept-App Apotheken favorisieren, so dass Sie schneller auf diese zugreifen können. Die Speicherung erfolgt nur auf Ihrem Endgerät. Die Daten werden verschlüsselt.

    @@ -183,9 +227,9 @@

    Werden meine Daten analysiert?

  • die Art des Netzwerkes (Mobilfunk / Wifi)
  • -

    von dem aus Sie zugreifen, die in der App aufgerufenen Screens (ohne den angezeigten Inhalt), die aktivierte Einstellungen in der App (App-Start abgesichert; Anmeldedaten gespeichert), die Anzahl fotografierter E-Rezepte, heruntergeladener E-Rezepte und eingelöster E-Rezepte, sowie die Zeitspanne zwischen Anfrage und Antwort von Servern des E-Rezept Systems. -Wir verarbeiten diese Daten, damit wir feststellen können, wann und welche Funktionen häufig benutzt werden, um die App besser gestalten zu können. Darüber hinaus dienen uns die Daten festzustellen, ob die eingesetzte Technik angepasst werden muss oder ob es bei den Nutzer Kompatibilitätsprobleme geben könnte. -Wir werden in keinem Fall Ihre Gesundheitsdaten nutzen, oder die Daten für werbliche Zwecke oder zur Identifikation von Personen verwenden. Die Daten werden nicht an Dritte wie z.B. Apotheken, Ärzte oder andere Einrichtungen weitergegeben.

    +

    von dem aus Sie zugreifen, die in der App aufgerufenen Screens (ohne den angezeigten Inhalt), die aktivierte Einstellungen in der App (App-Start abgesichert; Anmeldedaten gespeichert), die Anzahl fotografierter E-Rezepte, heruntergeladener E-Rezepte und eingelöster E-Rezepte, sowie die Zeitspanne zwischen Anfrage und Antwort von Servern des E-Rezept Systems.

    +

    Wir verarbeiten diese Daten, damit wir feststellen können, wann und welche Funktionen häufig benutzt werden, um die App besser gestalten zu können. Darüber hinaus dienen uns die Daten festzustellen, ob die eingesetzte Technik angepasst werden muss oder ob es bei den Nutzer Kompatibilitätsprobleme geben könnte.

    +

    Wir werden in keinem Fall Ihre Gesundheitsdaten nutzen, oder die Daten für werbliche Zwecke oder zur Identifikation von Personen verwenden. Die Daten werden nicht an Dritte wie z.B. Apotheken, Ärzte oder andere Einrichtungen weitergegeben.

    Welche Daten werden bei der Nutzung der Kartendienste in der App erhoben?

    @@ -392,7 +436,7 @@

    Betroffenenrechte

    Für die Ausübung Ihrer Rechte kontaktieren Sie uns bitte über das Kontaktformular unter folgendem Link: -https://www.gematik.de/hilfe-kontakt/kontaktformular/ – +https://www.gematik.de/hilfe-kontakt/kontaktformular/ – mit der Zuordnung des Anfragethemas „Datenschutz“.

    Wir sind nicht dazu verpflichtet Ihre Anfrage zu beantworten, wenn dies nur unter Aufhebung der Verschlüsselung der Daten möglich ist.

    diff --git a/android/src/main/assets/open_source_licenses.html b/android/src/main/assets/open_source_licenses.html deleted file mode 100644 index 605f2ffe..00000000 --- a/android/src/main/assets/open_source_licenses.html +++ /dev/null @@ -1,1445 +0,0 @@ - - - - Open source licenses - - -

    Notice for packages:

    -
      -
    • Bouncy Castle ASN.1 Extension and Utility APIs (1.69) -
      -
      Copyright © 20xx The Legion of the Bouncy Castle Inc.
      -
      -
    • -
    • Bouncy Castle PKIX, CMS, EAC, TSP, PKCS, OCSP, CMP, and CRMF APIs (1.69) -
      -
      Copyright © 20xx The Legion of the Bouncy Castle Inc.
      -
      -
    • -
    • Bouncy Castle Provider (1.69) -
      -
      Copyright © 20xx The Legion of the Bouncy Castle Inc.
      -
      -
    • - -
      Bouncy Castle Licence
      -http://www.bouncycastle.org/licence.html
      -
      -
      -
    • Accompanist FlowLayout library (0.20.2) -
      -
      Copyright © 20xx Google
      -
      -
    • -
    • Accompanist Insets library (0.20.2) -
      -
      Copyright © 20xx Google
      -
      -
    • -
    • Accompanist Insets UI library (0.20.2) -
      -
      Copyright © 20xx Google
      -
      -
    • -
    • Accompanist Pager Indicators (0.20.2) -
      -
      Copyright © 20xx Google
      -
      -
    • -
    • Accompanist Pager layouts (0.20.2) -
      -
      Copyright © 20xx Google
      -
      -
    • -
    • Accompanist System UI Controller library (0.20.2) -
      -
      Copyright © 20xx Google
      -
      -
    • -
    • Activity (1.1.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Activity (1.4.0-rc01) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Activity Compose (1.4.0-rc01) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Activity Kotlin Extensions (1.4.0-rc01) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android App Startup Runtime (1.0.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Android AppCompat Library (1.3.1) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Arch-Common (2.1.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Arch-Runtime (2.1.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android ConstraintLayout Core (1.0.1) -
      -
      Copyright © 2007 The Android Open Source Project
      -
      -
    • -
    • Android DataStore (1.0.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Android DataStore Core (1.0.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Android DB (2.1.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle Kotlin Extensions (2.3.1) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle LiveData (2.1.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle LiveData Core (2.3.1) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle Process (2.4.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle Runtime (2.3.1) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle ViewModel (2.3.1) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle ViewModel Kotlin Extensions (2.3.1) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle ViewModel with SavedState (2.3.1) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle-Common (2.3.1) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Lifecycle-Common for Java 8 Language (2.3.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Navigation Common (2.4.0-alpha10) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Navigation Common Kotlin Extensions (2.4.0-alpha10) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Navigation Hilt Extension (1.0.0) -
      -
      Copyright © 2021 The Android Open Source Project
      -
      -
    • -
    • Android Navigation Runtime (2.4.0-alpha10) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Navigation Runtime Kotlin Extensions (2.4.0-alpha10) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Paging-Common (3.1.0-alpha04) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Paging-Compose (1.0.0-alpha13) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Android Preferences DataStore (1.0.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Android Preferences DataStore Core (1.0.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Android Resources Library (1.3.1) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Android Room Kotlin Extensions (2.3.0) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Android Room-Common (2.3.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Room-Runtime (2.3.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Support AnimatedVectorDrawable (1.1.0) -
      -
      Copyright © 2015 The Android Open Source Project
      -
      -
    • -
    • Android Support DynamicAnimation (1.1.0-alpha03) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Support ExifInterface (1.3.2) -
      -
      Copyright © 2016 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Annotations (1.2.0) -
      -
      Copyright © 2013 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Async Layout Inflater (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library collections (1.1.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library compat (1.7.0-rc01) -
      -
      Copyright © 2015 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Coordinator Layout (1.0.0) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library core UI (1.0.0) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library core utils (1.0.0) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Cursor Adapter (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Custom View (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Custom View (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Document File (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Drawer Layout (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library fragment (1.3.6) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Interpolators (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library loader (1.0.0) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Local Broadcast Manager (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library media compat (1.0.0) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Print (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library Sliding Pane Layout (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support Library v4 (1.0.0) -
      -
      Copyright © 2011 The Android Open Source Project
      -
      -
    • -
    • Android Support Library View Pager (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Android Support SQLite - Framework Implementation (2.1.0) -
      -
      Copyright © 2017 The Android Open Source Project
      -
      -
    • -
    • Android Support VectorDrawable (1.1.0) -
      -
      Copyright © 2015 The Android Open Source Project
      -
      -
    • -
    • Android Tracing (1.0.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • AndroidX Autofill (1.0.0) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • AndroidX Futures (1.0.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • AndroidX Security (1.1.0-alpha03) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • androidx.profileinstaller:profileinstaller (1.0.4) -
      -
      Copyright © 2021 The Android Open Source Project
      -
      -
    • -
    • Apache Commons Codec (1.15) -
      -
      Copyright © 2002 Henri Yandell
      -
      Copyright © 2002 Tim OBrien
      -
      Copyright © 2002 Scott Sanders
      -
      Copyright © 2002 Rodney Waldhoff
      -
      Copyright © 2002 Daniel Rall
      -
      Copyright © 2002 Jon S. Stevens
      -
      Copyright © 2002 Gary Gregory
      -
      Copyright © 2002 David Graham
      -
      Copyright © 2002 Julius Davies
      -
      Copyright © 2002 Thomas Neidhart
      -
      Copyright © 2002 Rob Tompkins
      -
      -
    • -
    • Apache Commons IO (2.8.0) -
      -
      Copyright © 2002 Scott Sanders
      -
      Copyright © 2002 dIon Gillard
      -
      Copyright © 2002 Nicola Ken Barozzi
      -
      Copyright © 2002 Henri Yandell
      -
      Copyright © 2002 Stephen Colebourne
      -
      Copyright © 2002 Jeremias Maerki
      -
      Copyright © 2002 Matthew Hawthorne
      -
      Copyright © 2002 Martin Cooper
      -
      Copyright © 2002 Rob Oxspring
      -
      Copyright © 2002 Jochen Wiedmann
      -
      Copyright © 2002 Niall Pemberton
      -
      Copyright © 2002 Jukka Zitting
      -
      Copyright © 2002 Gary Gregory
      -
      Copyright © 2002 Kristian Rosenvold
      -
      -
    • -
    • Apache Commons Lang (3.12.0) -
      -
      Copyright © 2001 Daniel Rall
      -
      Copyright © 2001 Stephen Colebourne
      -
      Copyright © 2001 Henri Yandell
      -
      Copyright © 2001 Steven Caswell
      -
      Copyright © 2001 Robert Burrell Donkin
      -
      Copyright © 2001 Gary D. Gregory
      -
      Copyright © 2001 Fredrik Westermarck
      -
      Copyright © 2001 James Carman
      -
      Copyright © 2001 Niall Pemberton
      -
      Copyright © 2001 Matt Benson
      -
      Copyright © 2001 Joerg Schaible
      -
      Copyright © 2001 Oliver Heger
      -
      Copyright © 2001 Paul Benedict
      -
      Copyright © 2001 Benedikt Ritter
      -
      Copyright © 2001 Duncan Jones
      -
      Copyright © 2001 Loic Guibert
      -
      Copyright © 2001 Rob Tompkins
      -
      -
    • -
    • Apache Commons Text (1.9) -
      -
      Copyright © 2014 Bruno P. Kinoshita
      -
      Copyright © 2014 Benedikt Ritter
      -
      Copyright © 2014 Rob Tompkins
      -
      Copyright © 2014 Gary Gregory
      -
      Copyright © 2014 Duncan Jones
      -
      -
    • -
    • AutoValue Annotations (1.6.3) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Biometric (1.1.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Collections Kotlin Extensions (1.1.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Compose Animation (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Animation Core (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Compiler (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Foundation (1.0.5) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Compose Geometry (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Graphics (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Layouts (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Material Components (1.0.5) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Compose Material Icons Core (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Material Icons Extended (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Material Ripple (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Navigation (2.4.0-alpha10) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Runtime (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Saveable (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Tooling (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Tooling API (1.0.5) -
      -
      Copyright © 2021 The Android Open Source Project
      -
      -
    • -
    • Compose Tooling Data (1.0.5) -
      -
      Copyright © 2021 The Android Open Source Project
      -
      -
    • -
    • Compose UI primitives (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose UI Text (1.0.5) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Compose Unit (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Compose Util (1.0.5) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • ConstraintLayout for Jetpack Compose (1.0.0-rc01) -
      -
      Copyright © 2007 The Android Open Source Project
      -
      -
    • -
    • Converter: Moshi (2.9.0) -
      -
      Copyright © 20xx Square, Inc.
      -
      -
    • -
    • Core Kotlin Extensions (1.6.0) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • Dagger (2.39.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Dagger Lint Rules AAR Distribution (2.39.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Dynamic animation Kotlin Extensions (1.0.0-alpha03) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • error-prone annotations (2.5.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Experimental annotation (1.1.0) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • FindBugs-jsr305 (3.0.2) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • firebase-annotations (16.0.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • firebase-components (16.1.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • firebase-encoders (16.1.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • firebase-encoders-json (17.1.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Guava InternalFutureFailureAccess and InternalFutures (1.0.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Guava ListenableFuture only (9999.0-empty-to-avoid-conflict-with-guava) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Guava: Google Core Libraries for Java (30.1.1-jre) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • HAPI FHIR - Core Library (5.5.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • HAPI FHIR Structures - FHIR R4 (5.5.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Hilt Android (2.39.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Hilt Core (2.39.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • J2ObjC Annotations (1.3) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Jackson datatype: JSR310 (2.12.3) -
      -
      Copyright © 20xx Nick Williams
      -
      -
    • -
    • Jackson-annotations (2.12.3) -
      -
      Copyright © 2008 The original author or authors
      -
      -
    • -
    • Jackson-core (2.12.3) -
      -
      Copyright © 2008 The original author or authors
      -
      -
    • -
    • jackson-databind (2.12.3) -
      -
      Copyright © 2008 The original author or authors
      -
      -
    • -
    • javax.inject (1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • JCL 1.2 implemented over SLF4J (1.7.30) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • JetBrains Java Annotations (20.1.0) -
      -
      Copyright © 20xx JetBrains Team
      -
      -
    • -
    • Jetpack Camera Core Library (1.1.0-alpha09) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Jetpack Camera Library Camera2 Implementation/Extensions (1.1.0-alpha09) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Jetpack Camera Lifecycle Library (1.1.0-alpha09) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • Jetpack Camera View Library (1.0.0-alpha29) -
      -
      Copyright © 2019 The Android Open Source Project
      -
      -
    • -
    • jose4j (0.7.9) -
      -
      Copyright © 20xx Brian Campbell
      -
      -
    • -
    • Kotlin Android Extensions Runtime (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • Kotlin Reflect (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • Kotlin Stdlib (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • Kotlin Stdlib Common (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • Kotlin Stdlib Jdk7 (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • Kotlin Stdlib Jdk8 (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • kotlinx-coroutines-android (1.5.2) -
      -
      Copyright © 20xx JetBrains Team
      -
      -
    • -
    • kotlinx-coroutines-core (1.5.2) -
      -
      Copyright © 20xx JetBrains Team
      -
      -
    • -
    • kotlinx-coroutines-debug (1.5.2) -
      -
      Copyright © 20xx JetBrains Team
      -
      -
    • -
    • kotlinx-coroutines-test (1.5.2) -
      -
      Copyright © 20xx JetBrains Team
      -
      -
    • -
    • Lifecycle ViewModel Compose (2.4.0) -
      -
      Copyright © 2021 The Android Open Source Project
      -
      -
    • -
    • Moshi (1.12.0) -
      -
      Copyright © 2015 Square, Inc.
      -
      -
    • -
    • napier (1.4.1) -
      -
      Copyright © 20xx aakira
      -
      -
    • -
    • Navigation Compose Hilt Integration (1.0.0-alpha03) -
      -
      Copyright © 2021 The Android Open Source Project
      -
      -
    • -
    • okhttp (4.9.2) -
      -
      Copyright © 20xx Square, Inc.
      -
      -
    • -
    • okhttp-logging-interceptor (4.9.2) -
      -
      Copyright © 20xx Square, Inc.
      -
      -
    • -
    • Okio (2.10.0) -
      -
      Copyright © 20xx Square, Inc.
      -
      -
    • -
    • org.hl7.fhir.r4 (5.4.10) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • org.hl7.fhir.utilities (5.4.10) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • Parcelize Runtime (1.5.31) -
      -
      Copyright © 20xx Kotlin Team
      -
      -
    • -
    • Process Phoenix (2.1.2) -
      -
      Copyright © 20xx Jake Wharton
      -
      -
    • -
    • Retrofit (2.9.0) -
      -
      Copyright © 20xx Square, Inc.
      -
      -
    • -
    • SavedState Kotlin Extensions (1.1.0) -
      -
      Copyright © 2020 The Android Open Source Project
      -
      -
    • -
    • Snapper for Jetpack Compose (0.1.0) -
      -
      Copyright © 20xx Chris Banes
      -
      -
    • -
    • Timber (5.0.1) -
      -
      Copyright © 20xx Jake Wharton
      -
      -
    • -
    • Tink Cryptography API for Android (1.5.0) -
      -
      Copyright © 20xx
      -
      -
    • -
    • transport-api (2.2.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • transport-backend-cct (2.3.3) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • transport-runtime (2.2.6) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • VersionedParcelable (1.1.1) -
      -
      Copyright © 2018 The Android Open Source Project
      -
      -
    • -
    • ZXing Core (3.4.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • - -
                                       Apache License
      -                           Version 2.0, January 2004
      -                        http://www.apache.org/licenses/
      -
      -   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      -
      -   1. Definitions.
      -
      -      "License" shall mean the terms and conditions for use, reproduction,
      -      and distribution as defined by Sections 1 through 9 of this document.
      -
      -      "Licensor" shall mean the copyright owner or entity authorized by
      -      the copyright owner that is granting the License.
      -
      -      "Legal Entity" shall mean the union of the acting entity and all
      -      other entities that control, are controlled by, or are under common
      -      control with that entity. For the purposes of this definition,
      -      "control" means (i) the power, direct or indirect, to cause the
      -      direction or management of such entity, whether by contract or
      -      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      -      outstanding shares, or (iii) beneficial ownership of such entity.
      -
      -      "You" (or "Your") shall mean an individual or Legal Entity
      -      exercising permissions granted by this License.
      -
      -      "Source" form shall mean the preferred form for making modifications,
      -      including but not limited to software source code, documentation
      -      source, and configuration files.
      -
      -      "Object" form shall mean any form resulting from mechanical
      -      transformation or translation of a Source form, including but
      -      not limited to compiled object code, generated documentation,
      -      and conversions to other media types.
      -
      -      "Work" shall mean the work of authorship, whether in Source or
      -      Object form, made available under the License, as indicated by a
      -      copyright notice that is included in or attached to the work
      -      (an example is provided in the Appendix below).
      -
      -      "Derivative Works" shall mean any work, whether in Source or Object
      -      form, that is based on (or derived from) the Work and for which the
      -      editorial revisions, annotations, elaborations, or other modifications
      -      represent, as a whole, an original work of authorship. For the purposes
      -      of this License, Derivative Works shall not include works that remain
      -      separable from, or merely link (or bind by name) to the interfaces of,
      -      the Work and Derivative Works thereof.
      -
      -      "Contribution" shall mean any work of authorship, including
      -      the original version of the Work and any modifications or additions
      -      to that Work or Derivative Works thereof, that is intentionally
      -      submitted to Licensor for inclusion in the Work by the copyright owner
      -      or by an individual or Legal Entity authorized to submit on behalf of
      -      the copyright owner. For the purposes of this definition, "submitted"
      -      means any form of electronic, verbal, or written communication sent
      -      to the Licensor or its representatives, including but not limited to
      -      communication on electronic mailing lists, source code control systems,
      -      and issue tracking systems that are managed by, or on behalf of, the
      -      Licensor for the purpose of discussing and improving the Work, but
      -      excluding communication that is conspicuously marked or otherwise
      -      designated in writing by the copyright owner as "Not a Contribution."
      -
      -      "Contributor" shall mean Licensor and any individual or Legal Entity
      -      on behalf of whom a Contribution has been received by Licensor and
      -      subsequently incorporated within the Work.
      -
      -   2. Grant of Copyright License. Subject to the terms and conditions of
      -      this License, each Contributor hereby grants to You a perpetual,
      -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      -      copyright license to reproduce, prepare Derivative Works of,
      -      publicly display, publicly perform, sublicense, and distribute the
      -      Work and such Derivative Works in Source or Object form.
      -
      -   3. Grant of Patent License. Subject to the terms and conditions of
      -      this License, each Contributor hereby grants to You a perpetual,
      -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      -      (except as stated in this section) patent license to make, have made,
      -      use, offer to sell, sell, import, and otherwise transfer the Work,
      -      where such license applies only to those patent claims licensable
      -      by such Contributor that are necessarily infringed by their
      -      Contribution(s) alone or by combination of their Contribution(s)
      -      with the Work to which such Contribution(s) was submitted. If You
      -      institute patent litigation against any entity (including a
      -      cross-claim or counterclaim in a lawsuit) alleging that the Work
      -      or a Contribution incorporated within the Work constitutes direct
      -      or contributory patent infringement, then any patent licenses
      -      granted to You under this License for that Work shall terminate
      -      as of the date such litigation is filed.
      -
      -   4. Redistribution. You may reproduce and distribute copies of the
      -      Work or Derivative Works thereof in any medium, with or without
      -      modifications, and in Source or Object form, provided that You
      -      meet the following conditions:
      -
      -      (a) You must give any other recipients of the Work or
      -          Derivative Works a copy of this License; and
      -
      -      (b) You must cause any modified files to carry prominent notices
      -          stating that You changed the files; and
      -
      -      (c) You must retain, in the Source form of any Derivative Works
      -          that You distribute, all copyright, patent, trademark, and
      -          attribution notices from the Source form of the Work,
      -          excluding those notices that do not pertain to any part of
      -          the Derivative Works; and
      -
      -      (d) If the Work includes a "NOTICE" text file as part of its
      -          distribution, then any Derivative Works that You distribute must
      -          include a readable copy of the attribution notices contained
      -          within such NOTICE file, excluding those notices that do not
      -          pertain to any part of the Derivative Works, in at least one
      -          of the following places: within a NOTICE text file distributed
      -          as part of the Derivative Works; within the Source form or
      -          documentation, if provided along with the Derivative Works; or,
      -          within a display generated by the Derivative Works, if and
      -          wherever such third-party notices normally appear. The contents
      -          of the NOTICE file are for informational purposes only and
      -          do not modify the License. You may add Your own attribution
      -          notices within Derivative Works that You distribute, alongside
      -          or as an addendum to the NOTICE text from the Work, provided
      -          that such additional attribution notices cannot be construed
      -          as modifying the License.
      -
      -      You may add Your own copyright statement to Your modifications and
      -      may provide additional or different license terms and conditions
      -      for use, reproduction, or distribution of Your modifications, or
      -      for any such Derivative Works as a whole, provided Your use,
      -      reproduction, and distribution of the Work otherwise complies with
      -      the conditions stated in this License.
      -
      -   5. Submission of Contributions. Unless You explicitly state otherwise,
      -      any Contribution intentionally submitted for inclusion in the Work
      -      by You to the Licensor shall be under the terms and conditions of
      -      this License, without any additional terms or conditions.
      -      Notwithstanding the above, nothing herein shall supersede or modify
      -      the terms of any separate license agreement you may have executed
      -      with Licensor regarding such Contributions.
      -
      -   6. Trademarks. This License does not grant permission to use the trade
      -      names, trademarks, service marks, or product names of the Licensor,
      -      except as required for reasonable and customary use in describing the
      -      origin of the Work and reproducing the content of the NOTICE file.
      -
      -   7. Disclaimer of Warranty. Unless required by applicable law or
      -      agreed to in writing, Licensor provides the Work (and each
      -      Contributor provides its Contributions) on an "AS IS" BASIS,
      -      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      -      implied, including, without limitation, any warranties or conditions
      -      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      -      PARTICULAR PURPOSE. You are solely responsible for determining the
      -      appropriateness of using or redistributing the Work and assume any
      -      risks associated with Your exercise of permissions under this License.
      -
      -   8. Limitation of Liability. In no event and under no legal theory,
      -      whether in tort (including negligence), contract, or otherwise,
      -      unless required by applicable law (such as deliberate and grossly
      -      negligent acts) or agreed to in writing, shall any Contributor be
      -      liable to You for damages, including any direct, indirect, special,
      -      incidental, or consequential damages of any character arising as a
      -      result of this License or out of the use or inability to use the
      -      Work (including but not limited to damages for loss of goodwill,
      -      work stoppage, computer failure or malfunction, or any and all
      -      other commercial damages or losses), even if such Contributor
      -      has been advised of the possibility of such damages.
      -
      -   9. Accepting Warranty or Additional Liability. While redistributing
      -      the Work or Derivative Works thereof, You may choose to offer,
      -      and charge a fee for, acceptance of support, warranty, indemnity,
      -      or other liability obligations and/or rights consistent with this
      -      License. However, in accepting such obligations, You may act only
      -      on Your own behalf and on Your sole responsibility, not on behalf
      -      of any other Contributor, and only if You agree to indemnify,
      -      defend, and hold each Contributor harmless for any liability
      -      incurred by, or claims asserted against, such Contributor by reason
      -      of your accepting any such warranty or additional liability.
      -
      -   END OF TERMS AND CONDITIONS
      -
      -   APPENDIX: How to apply the Apache License to your work.
      -
      -      To apply the Apache License to your work, attach the following
      -      boilerplate notice, with the fields enclosed by brackets "[]"
      -      replaced with your own identifying information. (Don't include
      -      the brackets!)  The text should be enclosed in the appropriate
      -      comment syntax for the file format. We also recommend that a
      -      file or class name and description of purpose be included on the
      -      same "printed page" as the copyright notice for easier
      -      identification within third-party archives.
      -
      -   Copyright [yyyy] [name of copyright owner]
      -
      -   Licensed under the Apache License, Version 2.0 (the "License");
      -   you may not use this file except in compliance with the License.
      -   You may obtain a copy of the License at
      -
      -       http://www.apache.org/licenses/LICENSE-2.0
      -
      -   Unless required by applicable law or agreed to in writing, software
      -   distributed under the License is distributed on an "AS IS" BASIS,
      -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      -   See the License for the specific language governing permissions and
      -   limitations under the License.
      -
      -
      -
      -
    • barcode-scanning (17.0.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • common (17.3.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-mlkit-barcode-scanning (16.2.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • vision-common (16.5.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • - -
      ML Kit Terms of Service
      -https://developers.google.com/ml-kit/terms
      -
      -
      -
    • Java Native Access (5.5.0) -
      -
      Copyright © 20xx Timothy Wall
      -
      Copyright © 20xx Matthias Bläsing
      -
      -
    • -
    • Java Native Access Platform (5.5.0) -
      -
      Copyright © 20xx Timothy Wall
      -
      Copyright © 20xx Matthias Bläsing
      -
      -
    • - -
      LGPL, version 2.1
      -http://www.gnu.org/licenses/licenses.html
      -
      -
                                       Apache License
      -                           Version 2.0, January 2004
      -                        http://www.apache.org/licenses/
      -
      -   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      -
      -   1. Definitions.
      -
      -      "License" shall mean the terms and conditions for use, reproduction,
      -      and distribution as defined by Sections 1 through 9 of this document.
      -
      -      "Licensor" shall mean the copyright owner or entity authorized by
      -      the copyright owner that is granting the License.
      -
      -      "Legal Entity" shall mean the union of the acting entity and all
      -      other entities that control, are controlled by, or are under common
      -      control with that entity. For the purposes of this definition,
      -      "control" means (i) the power, direct or indirect, to cause the
      -      direction or management of such entity, whether by contract or
      -      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      -      outstanding shares, or (iii) beneficial ownership of such entity.
      -
      -      "You" (or "Your") shall mean an individual or Legal Entity
      -      exercising permissions granted by this License.
      -
      -      "Source" form shall mean the preferred form for making modifications,
      -      including but not limited to software source code, documentation
      -      source, and configuration files.
      -
      -      "Object" form shall mean any form resulting from mechanical
      -      transformation or translation of a Source form, including but
      -      not limited to compiled object code, generated documentation,
      -      and conversions to other media types.
      -
      -      "Work" shall mean the work of authorship, whether in Source or
      -      Object form, made available under the License, as indicated by a
      -      copyright notice that is included in or attached to the work
      -      (an example is provided in the Appendix below).
      -
      -      "Derivative Works" shall mean any work, whether in Source or Object
      -      form, that is based on (or derived from) the Work and for which the
      -      editorial revisions, annotations, elaborations, or other modifications
      -      represent, as a whole, an original work of authorship. For the purposes
      -      of this License, Derivative Works shall not include works that remain
      -      separable from, or merely link (or bind by name) to the interfaces of,
      -      the Work and Derivative Works thereof.
      -
      -      "Contribution" shall mean any work of authorship, including
      -      the original version of the Work and any modifications or additions
      -      to that Work or Derivative Works thereof, that is intentionally
      -      submitted to Licensor for inclusion in the Work by the copyright owner
      -      or by an individual or Legal Entity authorized to submit on behalf of
      -      the copyright owner. For the purposes of this definition, "submitted"
      -      means any form of electronic, verbal, or written communication sent
      -      to the Licensor or its representatives, including but not limited to
      -      communication on electronic mailing lists, source code control systems,
      -      and issue tracking systems that are managed by, or on behalf of, the
      -      Licensor for the purpose of discussing and improving the Work, but
      -      excluding communication that is conspicuously marked or otherwise
      -      designated in writing by the copyright owner as "Not a Contribution."
      -
      -      "Contributor" shall mean Licensor and any individual or Legal Entity
      -      on behalf of whom a Contribution has been received by Licensor and
      -      subsequently incorporated within the Work.
      -
      -   2. Grant of Copyright License. Subject to the terms and conditions of
      -      this License, each Contributor hereby grants to You a perpetual,
      -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      -      copyright license to reproduce, prepare Derivative Works of,
      -      publicly display, publicly perform, sublicense, and distribute the
      -      Work and such Derivative Works in Source or Object form.
      -
      -   3. Grant of Patent License. Subject to the terms and conditions of
      -      this License, each Contributor hereby grants to You a perpetual,
      -      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      -      (except as stated in this section) patent license to make, have made,
      -      use, offer to sell, sell, import, and otherwise transfer the Work,
      -      where such license applies only to those patent claims licensable
      -      by such Contributor that are necessarily infringed by their
      -      Contribution(s) alone or by combination of their Contribution(s)
      -      with the Work to which such Contribution(s) was submitted. If You
      -      institute patent litigation against any entity (including a
      -      cross-claim or counterclaim in a lawsuit) alleging that the Work
      -      or a Contribution incorporated within the Work constitutes direct
      -      or contributory patent infringement, then any patent licenses
      -      granted to You under this License for that Work shall terminate
      -      as of the date such litigation is filed.
      -
      -   4. Redistribution. You may reproduce and distribute copies of the
      -      Work or Derivative Works thereof in any medium, with or without
      -      modifications, and in Source or Object form, provided that You
      -      meet the following conditions:
      -
      -      (a) You must give any other recipients of the Work or
      -          Derivative Works a copy of this License; and
      -
      -      (b) You must cause any modified files to carry prominent notices
      -          stating that You changed the files; and
      -
      -      (c) You must retain, in the Source form of any Derivative Works
      -          that You distribute, all copyright, patent, trademark, and
      -          attribution notices from the Source form of the Work,
      -          excluding those notices that do not pertain to any part of
      -          the Derivative Works; and
      -
      -      (d) If the Work includes a "NOTICE" text file as part of its
      -          distribution, then any Derivative Works that You distribute must
      -          include a readable copy of the attribution notices contained
      -          within such NOTICE file, excluding those notices that do not
      -          pertain to any part of the Derivative Works, in at least one
      -          of the following places: within a NOTICE text file distributed
      -          as part of the Derivative Works; within the Source form or
      -          documentation, if provided along with the Derivative Works; or,
      -          within a display generated by the Derivative Works, if and
      -          wherever such third-party notices normally appear. The contents
      -          of the NOTICE file are for informational purposes only and
      -          do not modify the License. You may add Your own attribution
      -          notices within Derivative Works that You distribute, alongside
      -          or as an addendum to the NOTICE text from the Work, provided
      -          that such additional attribution notices cannot be construed
      -          as modifying the License.
      -
      -      You may add Your own copyright statement to Your modifications and
      -      may provide additional or different license terms and conditions
      -      for use, reproduction, or distribution of Your modifications, or
      -      for any such Derivative Works as a whole, provided Your use,
      -      reproduction, and distribution of the Work otherwise complies with
      -      the conditions stated in this License.
      -
      -   5. Submission of Contributions. Unless You explicitly state otherwise,
      -      any Contribution intentionally submitted for inclusion in the Work
      -      by You to the Licensor shall be under the terms and conditions of
      -      this License, without any additional terms or conditions.
      -      Notwithstanding the above, nothing herein shall supersede or modify
      -      the terms of any separate license agreement you may have executed
      -      with Licensor regarding such Contributions.
      -
      -   6. Trademarks. This License does not grant permission to use the trade
      -      names, trademarks, service marks, or product names of the Licensor,
      -      except as required for reasonable and customary use in describing the
      -      origin of the Work and reproducing the content of the NOTICE file.
      -
      -   7. Disclaimer of Warranty. Unless required by applicable law or
      -      agreed to in writing, Licensor provides the Work (and each
      -      Contributor provides its Contributions) on an "AS IS" BASIS,
      -      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      -      implied, including, without limitation, any warranties or conditions
      -      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      -      PARTICULAR PURPOSE. You are solely responsible for determining the
      -      appropriateness of using or redistributing the Work and assume any
      -      risks associated with Your exercise of permissions under this License.
      -
      -   8. Limitation of Liability. In no event and under no legal theory,
      -      whether in tort (including negligence), contract, or otherwise,
      -      unless required by applicable law (such as deliberate and grossly
      -      negligent acts) or agreed to in writing, shall any Contributor be
      -      liable to You for damages, including any direct, indirect, special,
      -      incidental, or consequential damages of any character arising as a
      -      result of this License or out of the use or inability to use the
      -      Work (including but not limited to damages for loss of goodwill,
      -      work stoppage, computer failure or malfunction, or any and all
      -      other commercial damages or losses), even if such Contributor
      -      has been advised of the possibility of such damages.
      -
      -   9. Accepting Warranty or Additional Liability. While redistributing
      -      the Work or Derivative Works thereof, You may choose to offer,
      -      and charge a fee for, acceptance of support, warranty, indemnity,
      -      or other liability obligations and/or rights consistent with this
      -      License. However, in accepting such obligations, You may act only
      -      on Your own behalf and on Your sole responsibility, not on behalf
      -      of any other Contributor, and only if You agree to indemnify,
      -      defend, and hold each Contributor harmless for any liability
      -      incurred by, or claims asserted against, such Contributor by reason
      -      of your accepting any such warranty or additional liability.
      -
      -   END OF TERMS AND CONDITIONS
      -
      -   APPENDIX: How to apply the Apache License to your work.
      -
      -      To apply the Apache License to your work, attach the following
      -      boilerplate notice, with the fields enclosed by brackets "[]"
      -      replaced with your own identifying information. (Don't include
      -      the brackets!)  The text should be enclosed in the appropriate
      -      comment syntax for the file format. We also recommend that a
      -      file or class name and description of purpose be included on the
      -      same "printed page" as the copyright notice for easier
      -      identification within third-party archives.
      -
      -   Copyright [yyyy] [name of copyright owner]
      -
      -   Licensed under the Apache License, Version 2.0 (the "License");
      -   you may not use this file except in compliance with the License.
      -   You may obtain a copy of the License at
      -
      -       http://www.apache.org/licenses/LICENSE-2.0
      -
      -   Unless required by applicable law or agreed to in writing, software
      -   distributed under the License is distributed on an "AS IS" BASIS,
      -   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      -   See the License for the specific language governing permissions and
      -   limitations under the License.
      -
      -
      -
      -
    • Piwik PRO SDK for Android (1.0.1) -
      -
      Copyright © 20xx Maksym Bura
      -
      -
    • - -
      BSD-3 Clause
      -https://github.com/piwikpro/piwik-pro-sdk-android/blob/master/LICENSE
      -
      -
      -
    • Checker Qual (3.8.0) -
      -
      Copyright © 20xx Michael Ernst
      -
      Copyright © 20xx Werner M. Dietl
      -
      Copyright © 20xx Suzanne Millstein
      -
      -
    • -
    • SLF4J API Module (1.7.30) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • zxcvbn4j (1.5.2) -
      -
      Copyright © 20xx Yuichi Watanabe
      -
      -
    • - -
      MIT License
      -
      -Copyright (c) [year] [fullname]
      -
      -Permission is hereby granted, free of charge, to any person obtaining a copy
      -of this software and associated documentation files (the "Software"), to deal
      -in the Software without restriction, including without limitation the rights
      -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      -copies of the Software, and to permit persons to whom the Software is
      -furnished to do so, subject to the following conditions:
      -
      -The above copyright notice and this permission notice shall be included in all
      -copies or substantial portions of the Software.
      -
      -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      -SOFTWARE.
      -
      -
      -
      -
    • android-database-sqlcipher (4.4.0) -
      -
      Copyright © 20xx Zetetic Support
      -
      -
    • - -
      https://www.zetetic.net/sqlcipher/license/
      -
      -
      -
    • image (1.0.0-beta1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-base (17.6.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-basement (17.6.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-location (18.0.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-places-placereport (17.0.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-safetynet (17.0.0) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • -
    • play-services-tasks (17.2.1) -
      -
      Copyright © 20xx The original author or authors
      -
      -
    • - -
      Android Software Development Kit License
      -https://developer.android.com/studio/terms.html
      -
      -
      -
    - - diff --git a/android/src/main/assets/open_source_licenses.json b/android/src/main/assets/open_source_licenses.json new file mode 100644 index 00000000..6d12fcf5 --- /dev/null +++ b/android/src/main/assets/open_source_licenses.json @@ -0,0 +1,3288 @@ +[ + { + "project": "Accompanist FlowLayout library", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-flowlayout:0.23.0" + }, + { + "project": "Accompanist Insets library", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-insets:0.23.0" + }, + { + "project": "Accompanist Insets UI library", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-insets-ui:0.23.0" + }, + { + "project": "Accompanist Pager Indicators", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-pager-indicators:0.23.0" + }, + { + "project": "Accompanist Pager layouts", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-pager:0.23.0" + }, + { + "project": "Accompanist SwipeRefresh library", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-swiperefresh:0.23.0" + }, + { + "project": "Accompanist System UI Controller library", + "description": "Utilities for Jetpack Compose", + "version": "0.23.0", + "developers": [ + "Google" + ], + "url": "https://github.com/google/accompanist/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.accompanist:accompanist-systemuicontroller:0.23.0" + }, + { + "project": "Activity", + "description": "Provides the base Activity subclass and the relevant hooks to build a composable structure on top.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.1.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.savedstate:savedstate:1.1.0" + }, + { + "project": "Activity", + "description": "Provides the base Activity subclass and the relevant hooks to build a composable structure on top.", + "version": "1.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/activity#1.4.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.activity:activity:1.4.0" + }, + { + "project": "Activity Compose", + "description": "Compose integration with Activity", + "version": "1.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/activity#1.4.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.activity:activity-compose:1.4.0" + }, + { + "project": "Activity Kotlin Extensions", + "description": "Kotlin extensions for \u0027activity\u0027 artifact", + "version": "1.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/activity#1.4.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.activity:activity-ktx:1.4.0" + }, + { + "project": "Android App Startup Runtime", + "description": "Android App Startup Runtime", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/startup#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.startup:startup-runtime:1.1.0" + }, + { + "project": "Android AppCompat Library", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.4.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/appcompat#1.4.1", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.appcompat:appcompat:1.4.1" + }, + { + "project": "Android Arch-Common", + "description": "Android Arch-Common", + "version": "2.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/topic/libraries/architecture/index.html", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.arch.core:core-common:2.1.0" + }, + { + "project": "Android Arch-Runtime", + "description": "Android Arch-Runtime", + "version": "2.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/topic/libraries/architecture/index.html", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.arch.core:core-runtime:2.1.0" + }, + { + "project": "Android DataStore", + "description": "Android DataStore - contains the underlying store used by each serialization method along with components that require an Android dependency", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/datastore#1.0.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.datastore:datastore:1.0.0" + }, + { + "project": "Android DataStore Core", + "description": "Android DataStore Core - contains the underlying store used by each serialization method", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/datastore#1.0.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.datastore:datastore-core:1.0.0" + }, + { + "project": "Android DB", + "description": "Android DB", + "version": "2.2.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/sqlite#2.2.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.sqlite:sqlite:2.2.0" + }, + { + "project": "Android Emoji2 Compat", + "description": "Core library to enable emoji compatibility in Kitkat and newer devices to avoid the empty emoji characters.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/emoji2#1.0.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.emoji2:emoji2:1.0.0" + }, + { + "project": "Android Emoji2 Compat view helpers", + "description": "View helpers for Emoji2", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/emoji2#1.0.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.emoji2:emoji2-views-helper:1.0.0" + }, + { + "project": "Android Lifecycle Kotlin Extensions", + "description": "Kotlin extensions for \u0027lifecycle\u0027 artifact", + "version": "2.3.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.3.1", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1" + }, + { + "project": "Android Lifecycle LiveData", + "description": "Android Lifecycle LiveData", + "version": "2.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/topic/libraries/architecture/index.html", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-livedata:2.1.0" + }, + { + "project": "Android Lifecycle LiveData Core", + "description": "Android Lifecycle LiveData Core", + "version": "2.3.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.3.1", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-livedata-core:2.3.1" + }, + { + "project": "Android Lifecycle Process", + "description": "Android Lifecycle Process", + "version": "2.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-process:2.4.0" + }, + { + "project": "Android Lifecycle Runtime", + "description": "Android Lifecycle Runtime", + "version": "2.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-runtime:2.4.0" + }, + { + "project": "Android Lifecycle ViewModel", + "description": "Android Lifecycle ViewModel", + "version": "2.3.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.3.1", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-viewmodel:2.3.1" + }, + { + "project": "Android Lifecycle ViewModel Kotlin Extensions", + "description": "Kotlin extensions for \u0027viewmodel\u0027 artifact", + "version": "2.3.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.3.1", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" + }, + { + "project": "Android Lifecycle ViewModel with SavedState", + "description": "Android Lifecycle ViewModel", + "version": "2.3.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.3.1", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1" + }, + { + "project": "Android Lifecycle-Common", + "description": "Android Lifecycle-Common", + "version": "2.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-common:2.4.0" + }, + { + "project": "Android Lifecycle-Common for Java 8 Language", + "description": "Android Lifecycle-Common for Java 8 Language", + "version": "2.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-common-java8:2.4.0" + }, + { + "project": "Android Navigation Common", + "description": "Android Navigation-Common", + "version": "2.4.2", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/navigation#2.4.2", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.navigation:navigation-common:2.4.2" + }, + { + "project": "Android Navigation Common Kotlin Extensions", + "description": "Android Navigation-Common-Ktx", + "version": "2.4.2", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/navigation#2.4.2", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.navigation:navigation-common-ktx:2.4.2" + }, + { + "project": "Android Navigation Runtime", + "description": "Android Navigation-Runtime", + "version": "2.4.2", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/navigation#2.4.2", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.navigation:navigation-runtime:2.4.2" + }, + { + "project": "Android Navigation Runtime Kotlin Extensions", + "description": "Android Navigation-Runtime-Ktx", + "version": "2.4.2", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/navigation#2.4.2", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.navigation:navigation-runtime-ktx:2.4.2" + }, + { + "project": "Android Paging-Common", + "description": "Android Paging-Common", + "version": "3.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/paging#3.1.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.paging:paging-common:3.1.0" + }, + { + "project": "Android Paging-Common Kotlin Extensions", + "description": "Kotlin extensions for \u0027paging-common\u0027 artifact", + "version": "3.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/paging#3.1.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.paging:paging-common-ktx:3.1.0" + }, + { + "project": "Android Paging-Compose", + "description": "Compose integration with Paging", + "version": "1.0.0-alpha14", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/paging#1.0.0-alpha14", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.paging:paging-compose:1.0.0-alpha14" + }, + { + "project": "Android Preferences DataStore", + "description": "Android Preferences DataStore", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/datastore#1.0.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.datastore:datastore-preferences:1.0.0" + }, + { + "project": "Android Preferences DataStore Core", + "description": "Android Preferences DataStore without the Android Dependencies", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/datastore#1.0.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.datastore:datastore-preferences-core:1.0.0" + }, + { + "project": "Android Resource Inspection - Annotations", + "description": "Annotation processors for Android resource and layout inspection", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/resourceinspection#1.0.0", + "year": "2021", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.resourceinspection:resourceinspection-annotation:1.0.0" + }, + { + "project": "Android Resources Library", + "description": "The Resources Library is a static library that you can add to your Android application in order to use resource APIs that backport the latest APIs to older versions of the platform. Compatible on devices running API 14 or later.", + "version": "1.4.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/appcompat#1.4.1", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.appcompat:appcompat-resources:1.4.1" + }, + { + "project": "Android Room Kotlin Extensions", + "description": "Android Room Kotlin Extensions", + "version": "2.4.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/room#2.4.1", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.room:room-ktx:2.4.1" + }, + { + "project": "Android Room-Common", + "description": "Android Room-Common", + "version": "2.4.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/room#2.4.1", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.room:room-common:2.4.1" + }, + { + "project": "Android Room-Runtime", + "description": "Android Room-Runtime", + "version": "2.4.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/room#2.4.1", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.room:room-runtime:2.4.1" + }, + { + "project": "Android Support AnimatedVectorDrawable", + "description": "Android Support AnimatedVectorDrawable", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx", + "year": "2015", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.vectordrawable:vectordrawable-animated:1.1.0" + }, + { + "project": "Android Support DynamicAnimation", + "description": "Physics-based animation in support library, where the animations are driven by physics force. You can use this Animation library to create smooth and realistic animations.", + "version": "1.1.0-alpha03", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.dynamicanimation:dynamicanimation:1.1.0-alpha03" + }, + { + "project": "Android Support ExifInterface", + "description": "Android Support ExifInterface", + "version": "1.3.3", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/exifinterface#1.3.3", + "year": "2016", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.exifinterface:exifinterface:1.3.3" + }, + { + "project": "Android Support Library Annotations", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs.", + "version": "1.3.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/annotation#1.3.0", + "year": "2013", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.annotation:annotation:1.3.0" + }, + { + "project": "Android Support Library Async Layout Inflater", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.asynclayoutinflater:asynclayoutinflater:1.0.0" + }, + { + "project": "Android Support Library collections", + "description": "Standalone efficient collections.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.collection:collection:1.1.0" + }, + { + "project": "Android Support Library compat", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.7.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/core#1.7.0", + "year": "2015", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.core:core:1.7.0" + }, + { + "project": "Android Support Library Coordinator Layout", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.coordinatorlayout:coordinatorlayout:1.0.0" + }, + { + "project": "Android Support Library core UI", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.legacy:legacy-support-core-ui:1.0.0" + }, + { + "project": "Android Support Library core utils", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.legacy:legacy-support-core-utils:1.0.0" + }, + { + "project": "Android Support Library Cursor Adapter", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.cursoradapter:cursoradapter:1.0.0" + }, + { + "project": "Android Support Library Custom View", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.customview:customview:1.0.0" + }, + { + "project": "Android Support Library Custom View", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" + }, + { + "project": "Android Support Library Document File", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.documentfile:documentfile:1.0.0" + }, + { + "project": "Android Support Library Drawer Layout", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.drawerlayout:drawerlayout:1.0.0" + }, + { + "project": "Android Support Library fragment", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.3.6", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/fragment#1.3.6", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.fragment:fragment:1.3.6" + }, + { + "project": "Android Support Library Interpolators", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.interpolator:interpolator:1.0.0" + }, + { + "project": "Android Support Library loader", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.loader:loader:1.0.0" + }, + { + "project": "Android Support Library Local Broadcast Manager", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.localbroadcastmanager:localbroadcastmanager:1.0.0" + }, + { + "project": "Android Support Library media compat", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.media:media:1.0.0" + }, + { + "project": "Android Support Library Print", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.print:print:1.0.0" + }, + { + "project": "Android Support Library Sliding Pane Layout", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.slidingpanelayout:slidingpanelayout:1.0.0" + }, + { + "project": "Android Support Library v4", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2011", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.legacy:legacy-support-v4:1.0.0" + }, + { + "project": "Android Support Library View Pager", + "description": "The Support Library is a static library that you can add to your Android application in order to use APIs that are either not available for older platform versions or utility APIs that aren\u0027t a part of the framework APIs. Compatible on devices running API 14 or later.", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.viewpager:viewpager:1.0.0" + }, + { + "project": "Android Support SQLite - Framework Implementation", + "description": "The implementation of Support SQLite library using the framework code.", + "version": "2.2.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/sqlite#2.2.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.sqlite:sqlite-framework:2.2.0" + }, + { + "project": "Android Support VectorDrawable", + "description": "Android Support VectorDrawable", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx", + "year": "2015", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.vectordrawable:vectordrawable:1.1.0" + }, + { + "project": "Android Tracing", + "description": "Android Tracing", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/tracing#1.0.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.tracing:tracing:1.0.0" + }, + { + "project": "android-database-sqlcipher", + "description": "SQLCipher for Android is a plugin to SQLite that provides full database encryption.", + "version": "4.5.0", + "developers": [ + "Zetetic Support" + ], + "url": "https://www.zetetic.net/sqlcipher", + "year": null, + "licenses": [ + { + "license": "", + "license_url": "https://www.zetetic.net/sqlcipher/license/" + } + ], + "dependency": "net.zetetic:android-database-sqlcipher:4.5.0" + }, + { + "project": "AndroidX Autofill", + "description": "AndroidX Autofill", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.autofill:autofill:1.0.0" + }, + { + "project": "AndroidX Futures", + "description": "Androidx implementation of Guava\u0027s ListenableFuture", + "version": "1.0.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/topic/libraries/architecture/index.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.concurrent:concurrent-futures:1.0.0" + }, + { + "project": "AndroidX Security", + "description": "AndroidX Security", + "version": "1.1.0-alpha03", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/security#1.1.0-alpha03", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.security:security-crypto:1.1.0-alpha03" + }, + { + "project": "androidx.profileinstaller:profileinstaller", + "description": "Allows libraries to prepopulate ahead of time compilation traces to be read by ART", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/profileinstaller#1.1.0", + "year": "2021", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.profileinstaller:profileinstaller:1.1.0" + }, + { + "project": "Apache Commons Codec", + "description": "The Apache Commons Codec package contains simple encoder and decoders for\n various formats such as Base64 and Hexadecimal. In addition to these\n widely used encoders and decoders, the codec package also maintains a\n collection of phonetic encoding utilities.", + "version": "1.15", + "developers": [ + "Henri Yandell", + "Tim OBrien", + "Scott Sanders", + "Rodney Waldhoff", + "Daniel Rall", + "Jon S. Stevens", + "Gary Gregory", + "David Graham", + "Julius Davies", + "Thomas Neidhart", + "Rob Tompkins" + ], + "url": "https://commons.apache.org/proper/commons-codec/", + "year": "2002", + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "commons-codec:commons-codec:1.15" + }, + { + "project": "Apache Commons IO", + "description": "The Apache Commons IO library contains utility classes, stream implementations, file filters,\nfile comparators, endian transformation classes, and much more.", + "version": "2.8.0", + "developers": [ + "Scott Sanders", + "dIon Gillard", + "Nicola Ken Barozzi", + "Henri Yandell", + "Stephen Colebourne", + "Jeremias Maerki", + "Matthew Hawthorne", + "Martin Cooper", + "Rob Oxspring", + "Jochen Wiedmann", + "Niall Pemberton", + "Jukka Zitting", + "Gary Gregory", + "Kristian Rosenvold" + ], + "url": "https://commons.apache.org/proper/commons-io/", + "year": "2002", + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "commons-io:commons-io:2.8.0" + }, + { + "project": "Apache Commons Lang", + "description": "Apache Commons Lang, a package of Java utility classes for the\n classes that are in java.lang\u0027s hierarchy, or are considered to be so\n standard as to justify existence in java.lang.", + "version": "3.12.0", + "developers": [ + "Daniel Rall", + "Stephen Colebourne", + "Henri Yandell", + "Steven Caswell", + "Robert Burrell Donkin", + "Gary D. Gregory", + "Fredrik Westermarck", + "James Carman", + "Niall Pemberton", + "Matt Benson", + "Joerg Schaible", + "Oliver Heger", + "Paul Benedict", + "Benedikt Ritter", + "Duncan Jones", + "Loic Guibert", + "Rob Tompkins" + ], + "url": "https://commons.apache.org/proper/commons-lang/", + "year": "2001", + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.apache.commons:commons-lang3:3.12.0" + }, + { + "project": "Apache Commons Text", + "description": "Apache Commons Text is a library focused on algorithms working on strings.", + "version": "1.9", + "developers": [ + "Bruno P. Kinoshita", + "Benedikt Ritter", + "Rob Tompkins", + "Gary Gregory", + "Duncan Jones" + ], + "url": "https://commons.apache.org/proper/commons-text", + "year": "2014", + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.apache.commons:commons-text:1.9" + }, + { + "project": "atomicfu", + "description": "AtomicFU utilities", + "version": "0.17.0", + "developers": [ + "JetBrains Team" + ], + "url": "https://github.com/Kotlin/kotlinx.atomicfu", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlinx:atomicfu-jvm:0.17.0" + }, + { + "project": "AutoValue Annotations", + "description": "Immutable value-type code generation for Java 1.6+.", + "version": "1.6.3", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Apache 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.auto.value:auto-value-annotations:1.6.3" + }, + { + "project": "barcode-scanning", + "description": null, + "version": "17.0.2", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "ML Kit Terms of Service", + "license_url": "https://developers.google.com/ml-kit/terms" + } + ], + "dependency": "com.google.mlkit:barcode-scanning:17.0.2" + }, + { + "project": "barcode-scanning-common", + "description": null, + "version": "17.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "ML Kit Terms of Service", + "license_url": "https://developers.google.com/ml-kit/terms" + } + ], + "dependency": "com.google.mlkit:barcode-scanning-common:17.0.0" + }, + { + "project": "Biometric", + "description": "The Biometric library is a static library that you can add to your Android application. It invokes BiometricPrompt on devices running P and greater, and on older devices will show a compat dialog. Compatible on devices running API 14 or later.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/biometric#1.1.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.biometric:biometric:1.1.0" + }, + { + "project": "Bouncy Castle ASN.1 Extension and Utility APIs", + "description": "The Bouncy Castle Java APIs for ASN.1 extension and utility APIs used to support bcpkix and bctls. This jar contains APIs for JDK 1.5 to JDK 1.8.", + "version": "1.70", + "developers": [ + "The Legion of the Bouncy Castle Inc." + ], + "url": "http://www.bouncycastle.org/java.html", + "year": null, + "licenses": [ + { + "license": "Bouncy Castle Licence", + "license_url": "http://www.bouncycastle.org/licence.html" + } + ], + "dependency": "org.bouncycastle:bcutil-jdk15to18:1.70" + }, + { + "project": "Bouncy Castle PKIX, CMS, EAC, TSP, PKCS, OCSP, CMP, and CRMF APIs", + "description": "The Bouncy Castle Java APIs for CMS, PKCS, EAC, TSP, CMP, CRMF, OCSP, and certificate generation. This jar contains APIs for JDK 1.5 to JDK 1.8. The APIs can be used in conjunction with a JCE/JCA provider such as the one provided with the Bouncy Castle Cryptography APIs.", + "version": "1.70", + "developers": [ + "The Legion of the Bouncy Castle Inc." + ], + "url": "http://www.bouncycastle.org/java.html", + "year": null, + "licenses": [ + { + "license": "Bouncy Castle Licence", + "license_url": "http://www.bouncycastle.org/licence.html" + } + ], + "dependency": "org.bouncycastle:bcpkix-jdk15to18:1.70" + }, + { + "project": "Bouncy Castle Provider", + "description": "The Bouncy Castle Crypto package is a Java implementation of cryptographic algorithms. This jar contains JCE provider and lightweight API for the Bouncy Castle Cryptography APIs for JDK 1.5 to JDK 1.8.", + "version": "1.70", + "developers": [ + "The Legion of the Bouncy Castle Inc." + ], + "url": "http://www.bouncycastle.org/java.html", + "year": null, + "licenses": [ + { + "license": "Bouncy Castle Licence", + "license_url": "http://www.bouncycastle.org/licence.html" + } + ], + "dependency": "org.bouncycastle:bcprov-jdk15to18:1.70" + }, + { + "project": "C Interop", + "description": "Wrapper for interacting with Realm Kotlin native code. This artifact is not supposed to be consumed directly, but through \u0027io.realm.kotlin:gradle-plugin:1.0.0\u0027 instead.", + "version": "1.0.0", + "developers": [ + "Realm" + ], + "url": "https://realm.io", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "io.realm.kotlin:cinterop-android:1.0.0" + }, + { + "project": "CanHub/Android-Image-Cropper", + "description": "Image Cropping Library for Android, optimised for Camera / Gallery.", + "version": "4.2.1", + "developers": [ + "CanHub" + ], + "url": "https://canhub.github.io/", + "year": "2020", + "licenses": [ + { + "license": "Apache License 2.0", + "license_url": "https://api.github.com/licenses/apache-2.0" + } + ], + "dependency": "com.github.CanHub:Android-Image-Cropper:4.2.1" + }, + { + "project": "Checker Qual", + "description": "Checker Qual is the set of annotations (qualifiers) and supporting classes\n used by the Checker Framework to type check Java source code.\n\n Please\n see artifact:\n org.checkerframework:checker", + "version": "3.8.0", + "developers": [ + "Michael Ernst", + "Werner M. Dietl", + "Suzanne Millstein" + ], + "url": "https://checkerframework.org", + "year": null, + "licenses": [ + { + "license": "The MIT License", + "license_url": "http://opensource.org/licenses/MIT" + } + ], + "dependency": "org.checkerframework:checker-qual:3.8.0" + }, + { + "project": "Collections Kotlin Extensions", + "description": "Kotlin extensions for \u0027collection\u0027 artifact", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.collection:collection-ktx:1.1.0" + }, + { + "project": "common", + "description": null, + "version": "18.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "ML Kit Terms of Service", + "license_url": "https://developers.google.com/ml-kit/terms" + } + ], + "dependency": "com.google.mlkit:common:18.0.0" + }, + { + "project": "Compose Animation", + "description": "Compose animation library", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-animation#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.animation:animation:1.1.0" + }, + { + "project": "Compose Animation Core", + "description": "Animation engine and animation primitives that are the building blocks of the Compose animation library", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-animation#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.animation:animation-core:1.1.0" + }, + { + "project": "Compose Foundation", + "description": "Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.1.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.foundation:foundation:1.1.0" + }, + { + "project": "Compose Geometry", + "description": "Compose classes related to dimensions without units", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-geometry:1.1.0" + }, + { + "project": "Compose Graphics", + "description": "Compose graphics", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-graphics:1.1.0" + }, + { + "project": "Compose Layouts", + "description": "Compose layout implementations", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.foundation:foundation-layout:1.1.0" + }, + { + "project": "Compose Material Components", + "description": "Compose Material Design Components library", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.1.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.material:material:1.1.0" + }, + { + "project": "Compose Material Icons Core", + "description": "Compose Material Design core icons. This module contains the most commonly used set of Material icons.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.material:material-icons-core:1.1.0" + }, + { + "project": "Compose Material Icons Extended", + "description": "Compose Material Design extended icons. This module contains all Material icons. It is a very large dependency and should not be included directly.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.material:material-icons-extended:1.1.0" + }, + { + "project": "Compose Material Ripple", + "description": "Material ripple used to build interactive components", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-material#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.material:material-ripple:1.1.0" + }, + { + "project": "Compose Navigation", + "description": "Compose integration with Navigation", + "version": "2.4.2", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/navigation#2.4.2", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.navigation:navigation-compose:2.4.2" + }, + { + "project": "Compose Runtime", + "description": "Tree composition support for code generated by the Compose compiler plugin and corresponding public API", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.runtime:runtime:1.1.0" + }, + { + "project": "Compose Saveable", + "description": "Compose components that allow saving and restoring the local ui state", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.runtime:runtime-saveable:1.1.0" + }, + { + "project": "Compose Tooling", + "description": "Compose tooling library. This library exposes information to our tools for better IDE support.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-tooling:1.1.0" + }, + { + "project": "Compose Tooling API", + "description": "Compose tooling library API. This library provides the API required to declare @Preview composables in user apps.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2021", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-tooling-preview:1.1.0" + }, + { + "project": "Compose Tooling Data", + "description": "Compose tooling library data. This library provides data about compose for different tooling purposes.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2021", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-tooling-data:1.1.0" + }, + { + "project": "Compose UI primitives", + "description": "Compose UI primitives. This library contains the primitives that form the Compose UI Toolkit, such as drawing, measurement and layout.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui:1.1.0" + }, + { + "project": "Compose UI Text", + "description": "Compose Text primitives and utilities", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-text:1.1.0" + }, + { + "project": "Compose Unit", + "description": "Compose classes for simple units", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-unit:1.1.0" + }, + { + "project": "Compose Util", + "description": "Internal Compose utilities used by other modules", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/compose-ui#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.compose.ui:ui-util:1.1.0" + }, + { + "project": "Core Kotlin Extensions", + "description": "Kotlin extensions for \u0027core\u0027 artifact", + "version": "1.7.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/core#1.7.0", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.core:core-ktx:1.7.0" + }, + { + "project": "Dynamic animation Kotlin Extensions", + "description": "Kotlin extensions for \u0027dynamicanimation\u0027 artifact", + "version": "1.0.0-alpha03", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03" + }, + { + "project": "error-prone annotations", + "description": null, + "version": "2.5.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Apache 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.errorprone:error_prone_annotations:2.5.1" + }, + { + "project": "Experimental annotation", + "description": "Java annotation for use on unstable Android API surfaces. When used in conjunction with the Experimental annotation lint checks, this annotation provides functional parity with Kotlin\u0027s Experimental annotation.", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/annotation#1.1.0", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.annotation:annotation-experimental:1.1.0" + }, + { + "project": "FindBugs-jsr305", + "description": "JSR305 Annotations for Findbugs", + "version": "3.0.2", + "developers": [], + "url": "http://findbugs.sourceforge.net/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.code.findbugs:jsr305:3.0.2" + }, + { + "project": "firebase-annotations", + "description": null, + "version": "16.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.firebase:firebase-annotations:16.0.0" + }, + { + "project": "firebase-components", + "description": null, + "version": "16.1.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.firebase:firebase-components:16.1.0" + }, + { + "project": "firebase-encoders", + "description": null, + "version": "16.1.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.firebase:firebase-encoders:16.1.0" + }, + { + "project": "firebase-encoders-json", + "description": null, + "version": "17.1.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.firebase:firebase-encoders-json:17.1.0" + }, + { + "project": "Fragment Kotlin Extensions", + "description": "Kotlin extensions for \u0027fragment\u0027 artifact", + "version": "1.3.6", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/fragment#1.3.6", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.fragment:fragment-ktx:1.3.6" + }, + { + "project": "Guava InternalFutureFailureAccess and InternalFutures", + "description": "Contains\n com.google.common.util.concurrent.internal.InternalFutureFailureAccess and\n InternalFutures. Most users will never need to use this artifact. Its\n classes is conceptually a part of Guava, but they\u0027re in this separate\n artifact so that Android libraries can use them without pulling in all of\n Guava (just as they can use ListenableFuture by depending on the\n listenablefuture artifact).", + "version": "1.0.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.guava:failureaccess:1.0.1" + }, + { + "project": "Guava ListenableFuture only", + "description": "An empty artifact that Guava depends on to signal that it is providing\n ListenableFuture -- but is also available in a second \"version\" that\n contains com.google.common.util.concurrent.ListenableFuture class, without\n any other Guava classes. The idea is:\n\n - If users want only ListenableFuture, they depend on listenablefuture-1.0.\n\n - If users want all of Guava, they depend on guava, which, as of Guava\n 27.0, depends on\n listenablefuture-9999.0-empty-to-avoid-conflict-with-guava. The 9999.0-...\n version number is enough for some build systems (notably, Gradle) to select\n that empty artifact over the \"real\" listenablefuture-1.0 -- avoiding a\n conflict with the copy of ListenableFuture in guava itself. If users are\n using an older version of Guava or a build system other than Gradle, they\n may see class conflicts. If so, they can solve them by manually excluding\n the listenablefuture artifact or manually forcing their build systems to\n use 9999.0-....", + "version": "9999.0-empty-to-avoid-conflict-with-guava", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava" + }, + { + "project": "Guava: Google Core Libraries for Java", + "description": "Guava is a suite of core and expanded libraries that include\n utility classes, Google\u0027s collections, I/O classes, and\n much more.", + "version": "30.1.1-jre", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.guava:guava:30.1.1-jre" + }, + { + "project": "HAPI FHIR - Core Library", + "description": null, + "version": "5.5.1", + "developers": [], + "url": "http://jamesagnew.github.io/hapi-fhir/", + "year": null, + "licenses": [ + { + "license": "Apache Software License 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "ca.uhn.hapi.fhir:hapi-fhir-base:5.5.1" + }, + { + "project": "HAPI FHIR Structures - FHIR R4", + "description": null, + "version": "5.5.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Apache Software License 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "ca.uhn.hapi.fhir:hapi-fhir-structures-r4:5.5.1" + }, + { + "project": "image", + "description": null, + "version": "1.0.0-beta1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.odml:image:1.0.0-beta1" + }, + { + "project": "IntelliJ IDEA Annotations", + "description": "A set of annotations used for code inspection support and code documentation.", + "version": "13.0", + "developers": [ + "JetBrains Team" + ], + "url": "http://www.jetbrains.org", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains:annotations:13.0" + }, + { + "project": "J2ObjC Annotations", + "description": "A set of annotations that provide additional information to the J2ObjC\n translator to modify the result of translation.", + "version": "1.3", + "developers": [], + "url": "https://github.com/google/j2objc/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.j2objc:j2objc-annotations:1.3" + }, + { + "project": "Jackson datatype: JSR310", + "description": "Add-on module to support JSR-310 (Java 8 Date \u0026 Time API) data types.", + "version": "2.12.3", + "developers": [ + "Nick Williams" + ], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.3" + }, + { + "project": "Jackson-annotations", + "description": "Core annotations used for value types, used by Jackson data binding package.", + "version": "2.12.3", + "developers": [], + "url": "http://github.com/FasterXML/jackson", + "year": "2008", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.fasterxml.jackson.core:jackson-annotations:2.12.3" + }, + { + "project": "Jackson-core", + "description": "Core Jackson processing abstractions (aka Streaming API), implementation for JSON", + "version": "2.12.3", + "developers": [], + "url": "https://github.com/FasterXML/jackson-core", + "year": "2008", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.fasterxml.jackson.core:jackson-core:2.12.3" + }, + { + "project": "jackson-databind", + "description": "General data-binding functionality for Jackson: works on core streaming API", + "version": "2.12.3", + "developers": [], + "url": "http://github.com/FasterXML/jackson", + "year": "2008", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.fasterxml.jackson.core:jackson-databind:2.12.3" + }, + { + "project": "javax.inject", + "description": "The javax.inject API", + "version": "1", + "developers": [], + "url": "http://code.google.com/p/atinject/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "javax.inject:javax.inject:1" + }, + { + "project": "JCL 1.2 implemented over SLF4J", + "description": "JCL 1.2 implemented over SLF4J", + "version": "1.7.30", + "developers": [], + "url": "http://www.slf4j.org", + "year": null, + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.slf4j:jcl-over-slf4j:1.7.30" + }, + { + "project": "Jetpack Camera Core Library", + "description": "Core components for the Jetpack Camera Library, a library providing a consistent and reliable camera foundation that enables great camera driven experiences across all of Android.", + "version": "1.1.0-alpha12", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/camera#1.1.0-alpha12", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + }, + { + "license": "BSD License", + "license_url": "https://chromium.googlesource.com/libyuv/libyuv/+/refs/heads/main/README.chromium" + } + ], + "dependency": "androidx.camera:camera-core:1.1.0-alpha12" + }, + { + "project": "Jetpack Camera Library Camera2 Implementation/Extensions", + "description": "Camera2 implementation and extensions for the Jetpack Camera Library, a library providing a consistent and reliable camera foundation that enables great camera driven experiences across all of Android.", + "version": "1.1.0-alpha12", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/camera#1.1.0-alpha12", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.camera:camera-camera2:1.1.0-alpha12" + }, + { + "project": "Jetpack Camera Lifecycle Library", + "description": "Lifecycle components for the Jetpack Camera Library, a library providing a consistent and reliable camera foundation that enables great camera driven experiences across all of Android.", + "version": "1.1.0-alpha12", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/camera#1.1.0-alpha12", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.camera:camera-lifecycle:1.1.0-alpha12" + }, + { + "project": "Jetpack Camera View Library", + "description": "UI tools for the Jetpack Camera Library, a library providing a consistent and reliable camera foundation that enables great camera driven experiences across all of Android.", + "version": "1.0.0-alpha32", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/camera#1.0.0-alpha32", + "year": "2019", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.camera:camera-view:1.0.0-alpha32" + }, + { + "project": "JNI Swig Stubs", + "description": "Wrapper for interacting with Realm Kotlin native code from the JVM. This artifact is not supposed to be consumed directly, but through \u0027io.realm.kotlin:gradle-plugin:1.0.0\u0027 instead.", + "version": "1.0.0", + "developers": [ + "Realm" + ], + "url": "https://realm.io", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "io.realm.kotlin:jni-swig-stub:1.0.0" + }, + { + "project": "jose4j", + "description": "The jose.4.j library is a robust and easy to use open source implementation of JSON Web Token (JWT) and the JOSE specification suite (JWS, JWE, and JWK).\n It is written in Java and relies solely on the JCA APIs for cryptography.\n Please see https://bitbucket.org/b_c/jose4j/wiki/Home for more info, examples, etc..", + "version": "0.7.12", + "developers": [ + "Brian Campbell" + ], + "url": "https://bitbucket.org/b_c/jose4j/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.bitbucket.b_c:jose4j:0.7.12" + }, + { + "project": "Kodein-DI", + "description": "KODEIN Dependency Injection Core", + "version": "7.11.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.di:kodein-di-jvm:7.11.0" + }, + { + "project": "Kodein-DI-Framework-Android", + "description": "Kodein-DI extensions with AndroidX compatibility", + "version": "7.11.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.di:kodein-di-framework-android-x:7.11.0" + }, + { + "project": "Kodein-DI-Framework-Android", + "description": "Standard Kodein DI classes \u0026 extensions for Android", + "version": "7.11.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.di:kodein-di-framework-android-core:7.11.0" + }, + { + "project": "Kodein-DI-Framework-AndroidX-ViewModel", + "description": "Kodein-DI extensions for AndroidX ViewModel", + "version": "7.11.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.di:kodein-di-framework-android-x-viewmodel:7.11.0" + }, + { + "project": "Kodein-DI-Framework-AndroidX-ViewModel-SavedState", + "description": "Kodein-DI extensions for AndroidX ViewModel with SavedStateHandle", + "version": "7.11.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.di:kodein-di-framework-android-x-viewmodel-savedstate:7.11.0" + }, + { + "project": "Kodein-DI-Framework-Compose", + "description": "Kodein-DI extensions for Jetpack / JetBrains Compose", + "version": "7.11.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.di:kodein-di-framework-compose-android:7.11.0" + }, + { + "project": "Kodein-Type", + "description": "Kodein Type System", + "version": "1.12.0", + "developers": [ + "Kodein Koders" + ], + "url": "http://kodein.org", + "year": null, + "licenses": [ + { + "license": "MIT", + "license_url": "https://opensource.org/licenses/MIT" + } + ], + "dependency": "org.kodein.type:kodein-type-jvm:1.12.0" + }, + { + "project": "Kotlin Android Extensions Runtime", + "description": "Kotlin Android Extensions Runtime", + "version": "1.6.10", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.6.10" + }, + { + "project": "Kotlin Reflect", + "description": "Kotlin Full Reflection Library", + "version": "1.6.10", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-reflect:1.6.10" + }, + { + "project": "Kotlin Stdlib", + "description": "Kotlin Standard Library for JVM", + "version": "1.6.21", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-stdlib:1.6.21" + }, + { + "project": "Kotlin Stdlib Common", + "description": "Kotlin Common Standard Library", + "version": "1.6.21", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-stdlib-common:1.6.21" + }, + { + "project": "Kotlin Stdlib Jdk7", + "description": "Kotlin Standard Library JDK 7 extension", + "version": "1.6.21", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21" + }, + { + "project": "Kotlin Stdlib Jdk8", + "description": "Kotlin Standard Library JDK 8 extension", + "version": "1.6.21", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21" + }, + { + "project": "kotlinx-coroutines-android", + "description": "Coroutines support libraries for Kotlin", + "version": "1.6.1", + "developers": [ + "JetBrains Team" + ], + "url": "https://github.com/Kotlin/kotlinx.coroutines", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1" + }, + { + "project": "kotlinx-coroutines-core", + "description": "Coroutines support libraries for Kotlin", + "version": "1.6.1", + "developers": [ + "JetBrains Team" + ], + "url": "https://github.com/Kotlin/kotlinx.coroutines", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.1" + }, + { + "project": "kotlinx-serialization-core", + "description": "Kotlin multiplatform serialization runtime library", + "version": "1.3.3", + "developers": [ + "JetBrains Team" + ], + "url": "https://github.com/Kotlin/kotlinx.serialization", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.3.3" + }, + { + "project": "kotlinx-serialization-json", + "description": "Kotlin multiplatform serialization runtime library", + "version": "1.3.3", + "developers": [ + "JetBrains Team" + ], + "url": "https://github.com/Kotlin/kotlinx.serialization", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.3.3" + }, + { + "project": "Library", + "description": "Library code for Realm Kotlin. This artifact is not supposed to be consumed directly, but through \u0027io.realm.kotlin:gradle-plugin:1.0.0\u0027 instead.", + "version": "1.0.0", + "developers": [ + "Realm" + ], + "url": "https://realm.io", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "io.realm.kotlin:library-base-android:1.0.0" + }, + { + "project": "Lifecycle ViewModel Compose", + "description": "Compose integration with Lifecycle ViewModel", + "version": "2.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.4.0", + "year": "2021", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0" + }, + { + "project": "LiveData Core Kotlin Extensions", + "description": "Kotlin extensions for \u0027livedata-core\u0027 artifact", + "version": "2.3.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/lifecycle#2.3.1", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.lifecycle:lifecycle-livedata-core-ktx:2.3.1" + }, + { + "project": "Lottie", + "description": "Lottie is an animation library that renders Adobe After Effects animations natively in realtime.", + "version": "5.0.3", + "developers": [ + "Airbnb" + ], + "url": "https://github.com/airbnb/lottie-android", + "year": "2017", + "licenses": [ + { + "license": "Apache-2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0" + } + ], + "dependency": "com.airbnb.android:lottie:5.0.3" + }, + { + "project": "Lottie Compose", + "description": "Lottie for Jetpack Compose.", + "version": "5.0.3", + "developers": [ + "Airbnb" + ], + "url": "https://github.com/airbnb/lottie-android", + "year": "2020", + "licenses": [ + { + "license": "Apache-2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0" + } + ], + "dependency": "com.airbnb.android:lottie-compose:5.0.3" + }, + { + "project": "napier", + "description": "Kotlin Multiplatform libraries that show logs in common module.", + "version": "2.6.1", + "developers": [ + "aakira" + ], + "url": "https://github.com/aakira/Napier", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "io.github.aakira:napier-android:2.6.1" + }, + { + "project": "okhttp", + "description": "Square’s meticulous HTTP client for Java and Kotlin.", + "version": "4.9.2", + "developers": [ + "Square, Inc." + ], + "url": "https://square.github.io/okhttp/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.squareup.okhttp3:okhttp:4.9.2" + }, + { + "project": "okhttp-logging-interceptor", + "description": "Square’s meticulous HTTP client for Java and Kotlin.", + "version": "4.9.2", + "developers": [ + "Square, Inc." + ], + "url": "https://square.github.io/okhttp/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.squareup.okhttp3:logging-interceptor:4.9.2" + }, + { + "project": "Okio", + "description": "A modern I/O API for Java", + "version": "2.8.0", + "developers": [ + "Square, Inc." + ], + "url": "https://github.com/square/okio/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.squareup.okio:okio:2.8.0" + }, + { + "project": "org.hl7.fhir.r4", + "description": null, + "version": "5.4.10", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Apache Software License 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "ca.uhn.hapi.fhir:org.hl7.fhir.r4:5.4.10" + }, + { + "project": "org.hl7.fhir.utilities", + "description": null, + "version": "5.4.10", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Apache Software License 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "ca.uhn.hapi.fhir:org.hl7.fhir.utilities:5.4.10" + }, + { + "project": "Parcelize Runtime", + "description": "Runtime library for the Parcelize compiler plugin", + "version": "1.6.10", + "developers": [ + "Kotlin Team" + ], + "url": "https://kotlinlang.org/", + "year": null, + "licenses": [ + { + "license": "The Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "org.jetbrains.kotlin:kotlin-parcelize-runtime:1.6.10" + }, + { + "project": "play-services-base", + "description": null, + "version": "18.0.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.gms:play-services-base:18.0.1" + }, + { + "project": "play-services-basement", + "description": null, + "version": "18.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.gms:play-services-basement:18.0.0" + }, + { + "project": "play-services-location", + "description": null, + "version": "19.0.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.gms:play-services-location:19.0.1" + }, + { + "project": "play-services-mlkit-barcode-scanning", + "description": null, + "version": "18.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "ML Kit Terms of Service", + "license_url": "https://developers.google.com/ml-kit/terms" + } + ], + "dependency": "com.google.android.gms:play-services-mlkit-barcode-scanning:18.0.0" + }, + { + "project": "play-services-places-placereport", + "description": null, + "version": "17.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.gms:play-services-places-placereport:17.0.0" + }, + { + "project": "play-services-safetynet", + "description": null, + "version": "18.0.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.gms:play-services-safetynet:18.0.1" + }, + { + "project": "play-services-tasks", + "description": null, + "version": "18.0.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "Android Software Development Kit License", + "license_url": "https://developer.android.com/studio/terms.html" + } + ], + "dependency": "com.google.android.gms:play-services-tasks:18.0.1" + }, + { + "project": "Retrofit", + "description": "A type-safe HTTP client for Android and Java.", + "version": "2.9.0", + "developers": [ + "Square, Inc." + ], + "url": "https://github.com/square/retrofit", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.squareup.retrofit2:retrofit:2.9.0" + }, + { + "project": "Retrofit 2 Kotlin Serialization Converter", + "description": "A Converter.Factory for Kotlin\u0027s serialization support.", + "version": "0.8.0", + "developers": [ + "Jake Wharton" + ], + "url": "https://github.com/JakeWharton/retrofit2-kotlinx-serialization-converter/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0" + }, + { + "project": "SavedState Kotlin Extensions", + "description": "Kotlin extensions for \u0027savedstate\u0027 artifact", + "version": "1.1.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/savedstate#1.1.0", + "year": "2020", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.savedstate:savedstate-ktx:1.1.0" + }, + { + "project": "SLF4J API Module", + "description": "The slf4j API", + "version": "1.7.30", + "developers": [], + "url": "http://www.slf4j.org", + "year": null, + "licenses": [ + { + "license": "MIT License", + "license_url": "http://www.opensource.org/licenses/mit-license.php" + } + ], + "dependency": "org.slf4j:slf4j-api:1.7.30" + }, + { + "project": "Snapper for Jetpack Compose", + "description": "Snapper for Jetpack Compose", + "version": "0.1.2", + "developers": [ + "Chris Banes" + ], + "url": "https://github.com/chrisbanes/snapper/", + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "dev.chrisbanes.snapper:snapper:0.1.2" + }, + { + "project": "Tink Cryptography API for Android", + "description": "Tink is a small cryptographic library that provides a safe, simple, agile and fast way to accomplish some common cryptographic tasks.", + "version": "1.5.0", + "developers": [ + "" + ], + "url": "http://github.com/google/tink", + "year": null, + "licenses": [ + { + "license": "Apache License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.crypto.tink:tink-android:1.5.0" + }, + { + "project": "transport-api", + "description": null, + "version": "2.2.1", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.android.datatransport:transport-api:2.2.1" + }, + { + "project": "transport-backend-cct", + "description": null, + "version": "2.3.3", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.android.datatransport:transport-backend-cct:2.3.3" + }, + { + "project": "transport-runtime", + "description": null, + "version": "2.2.6", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.android.datatransport:transport-runtime:2.2.6" + }, + { + "project": "VersionedParcelable", + "description": "Provides a stable but relatively compact binary serialization format that can be passed across processes or persisted safely.", + "version": "1.1.1", + "developers": [ + "The Android Open Source Project" + ], + "url": "http://developer.android.com/tools/extras/support-library.html", + "year": "2018", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.versionedparcelable:versionedparcelable:1.1.1" + }, + { + "project": "viewbinding", + "description": null, + "version": "7.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.databinding:viewbinding:7.0.0" + }, + { + "project": "vision-common", + "description": null, + "version": "17.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "ML Kit Terms of Service", + "license_url": "https://developers.google.com/ml-kit/terms" + } + ], + "dependency": "com.google.mlkit:vision-common:17.0.0" + }, + { + "project": "vision-interfaces", + "description": null, + "version": "16.0.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "ML Kit Terms of Service", + "license_url": "https://developers.google.com/ml-kit/terms" + } + ], + "dependency": "com.google.mlkit:vision-interfaces:16.0.0" + }, + { + "project": "WebView Support Library", + "description": "The WebView Support Library is a static library you can add to your Android application in order to use android.webkit APIs that are not available for older platform versions.", + "version": "1.4.0", + "developers": [ + "The Android Open Source Project" + ], + "url": "https://developer.android.com/jetpack/androidx/releases/webkit#1.4.0", + "year": "2017", + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "http://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "androidx.webkit:webkit:1.4.0" + }, + { + "project": "zxcvbn4j", + "description": "This is a java port of zxcvbn, which is a JavaScript password strength generator.", + "version": "1.7.0", + "developers": [ + "Yuichi Watanabe" + ], + "url": "https://github.com/nulab/zxcvbn4j", + "year": null, + "licenses": [ + { + "license": "MIT License", + "license_url": "http://www.opensource.org/licenses/mit-license.php" + } + ], + "dependency": "com.nulab-inc:zxcvbn:1.7.0" + }, + { + "project": "ZXing Core", + "description": "Core barcode encoding/decoding library", + "version": "3.5.0", + "developers": [], + "url": null, + "year": null, + "licenses": [ + { + "license": "The Apache Software License, Version 2.0", + "license_url": "https://www.apache.org/licenses/LICENSE-2.0.txt" + } + ], + "dependency": "com.google.zxing:core:3.5.0" + } +] \ No newline at end of file diff --git a/android/src/main/assets/terms_of_use.html b/android/src/main/assets/terms_of_use.html index d83ab848..82b7e0be 100644 --- a/android/src/main/assets/terms_of_use.html +++ b/android/src/main/assets/terms_of_use.html @@ -1,10 +1,11 @@ - + 2021-07-02_Nutzungsbedingungen E-Rezept-App + - +

    Nutzungsbedingungen E-Rezept-App

    (Stand: Juli 2021)

    @@ -21,7 +22,6 @@

    Inhalt

  • Informationen Dritter in der App
  • Datenschutz
  • Schlussbestimmungen
  • - diff --git a/android/src/main/java/de/gematik/ti/erp/app/App.kt b/android/src/main/java/de/gematik/ti/erp/app/App.kt index af02cfcd..7c95abe6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/App.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/App.kt @@ -21,33 +21,38 @@ package de.gematik.ti.erp.app import android.app.Application import android.content.Context import androidx.lifecycle.ProcessLifecycleOwner -import dagger.hilt.android.HiltAndroidApp -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase +import de.gematik.ti.erp.app.di.allModules import de.gematik.ti.erp.app.userauthentication.ui.AuthenticationUseCase -import org.bouncycastle.jce.provider.BouncyCastleProvider -import timber.log.Timber -import javax.inject.Inject +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import org.kodein.di.DI +import org.kodein.di.DIAware +import org.kodein.di.android.x.androidXModule +import org.kodein.di.bindSingleton +import org.kodein.di.instance -val BCProvider = BouncyCastleProvider() +class App : Application(), DIAware { -@HiltAndroidApp -class App : Application() { + override val di by DI.lazy { + import(androidXModule(this@App)) + importAll(allModules) + bindSingleton { AuthenticationUseCase(instance(), instance()) } + bindSingleton { VisibleDebugTree() } + } - @Inject - lateinit var demoUseCase: DemoUseCase + private val authUseCase: AuthenticationUseCase by instance() - @Inject - lateinit var authUseCase: AuthenticationUseCase + private val visibleDebugTree: VisibleDebugTree by instance() override fun onCreate() { super.onCreate() appContext = this if (BuildKonfig.INTERNAL) { - Timber.plant(Timber.DebugTree()) + Napier.base(DebugAntilog()) + Napier.base(visibleDebugTree) } ProcessLifecycleOwner.get().lifecycle.apply { - addObserver(demoUseCase) addObserver(authUseCase) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/LegalNoticeScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/LegalNoticeScreen.kt index 5cd10934..182f0fbf 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/LegalNoticeScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/LegalNoticeScreen.kt @@ -20,6 +20,7 @@ package de.gematik.ti.erp.app import android.content.Context import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,8 +29,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Email @@ -50,32 +49,34 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar import de.gematik.ti.erp.app.utils.compose.canHandleIntent import de.gematik.ti.erp.app.utils.compose.createToastShort import de.gematik.ti.erp.app.utils.compose.handleIntent import de.gematik.ti.erp.app.utils.compose.provideEmailIntent import de.gematik.ti.erp.app.utils.compose.providePhoneIntent +import java.util.* @Composable fun LegalNoticeWithScaffold(navigation: NavHostController) { val header = stringResource(id = R.string.legal_notice_menu) - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = header, - onBack = { navigation.popBackStack() } - ) - } + + val scrollState = rememberScrollState() + + AnimatedElevationScaffold( + elevated = scrollState.value > 0, + navigationMode = NavigationBarMode.Back, + actions = {}, + topBarTitle = header, + onBack = { navigation.popBackStack() } ) { innerPadding -> - LegalNoticeScreen(Modifier.padding(innerPadding)) + LegalNoticeScreen(Modifier.padding(innerPadding), scrollState) } } @Composable -fun LegalNoticeScreen(modifier: Modifier) { +fun LegalNoticeScreen(modifier: Modifier, scrollState: ScrollState) { val issuer = stringResource(id = R.string.legal_notice_issuer) val address = stringResource(id = R.string.legal_notice_address) val info = stringResource(id = R.string.legal_notice_info) @@ -91,13 +92,13 @@ fun LegalNoticeScreen(modifier: Modifier) { verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) ) { Text( text = issuer, modifier = modifier .padding(top = 24.dp), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) Text(text = address, modifier = modifier) Text(text = info, modifier = modifier) @@ -105,7 +106,7 @@ fun LegalNoticeScreen(modifier: Modifier) { Text( text = responsibleForHeader, modifier = modifier.padding(top = 24.dp), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) Text(text = responsibleForName, modifier = modifier) @@ -114,12 +115,13 @@ fun LegalNoticeScreen(modifier: Modifier) { Text( text = hintHeader, modifier = modifier.padding(top = 24.dp), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) Text(text = hint, modifier = modifier) Image( - logo, null, + logo, + null, modifier = Modifier .padding(top = 32.dp) .align(Alignment.CenterHorizontally) @@ -127,7 +129,7 @@ fun LegalNoticeScreen(modifier: Modifier) { Text( text = logoText, modifier = Modifier.align(Alignment.CenterHorizontally), - style = MaterialTheme.typography.body2 + style = AppTheme.typography.body2 ) } } @@ -139,7 +141,7 @@ fun Contact(modifier: Modifier) { Text( text = contactHeader, modifier = modifier.padding(top = 24.dp), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) LinkToWeb( linkInfo = stringResource(id = R.string.menu_legal_notice_url_info), @@ -258,7 +260,8 @@ fun provideLinkForString( color = linkColor, textDecoration = TextDecoration.Underline ), - start = start, end = end + start = start, + end = end ) addStringAnnotation( tag = tag, diff --git a/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt b/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt index 69227a47..2ffde2c0 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/MainActivity.kt @@ -18,86 +18,216 @@ package de.gematik.ti.erp.app +import android.app.Activity +import android.content.Intent import android.content.SharedPreferences import android.nfc.NfcAdapter import android.nfc.Tag import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.core.content.edit +import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController -import dagger.hilt.android.AndroidEntryPoint +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.model.AppUpdateType.IMMEDIATE +import com.google.android.play.core.install.model.UpdateAvailability +import de.gematik.ti.erp.app.cardunlock.ui.UnlockEgkViewModel +import de.gematik.ti.erp.app.cardwall.mini.ui.ExternalAuthPrompt +import de.gematik.ti.erp.app.cardwall.mini.ui.HealthCardPrompt +import de.gematik.ti.erp.app.cardwall.mini.ui.MiniCardWallViewModel +import de.gematik.ti.erp.app.cardwall.mini.ui.rememberAuthenticator +import de.gematik.ti.erp.app.cardwall.ui.CardWallNfcPositionViewModel +import de.gematik.ti.erp.app.cardwall.ui.CardWallViewModel +import de.gematik.ti.erp.app.cardwall.ui.ExternalAuthenticatorListViewModel import de.gematik.ti.erp.app.core.LocalActivity -import de.gematik.ti.erp.app.core.LocalTracker +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.core.LocalAnalytics import de.gematik.ti.erp.app.core.MainContent -import de.gematik.ti.erp.app.di.ApplicationPreferences -import de.gematik.ti.erp.app.di.NavigationObservable +import de.gematik.ti.erp.app.core.MainViewModel +import de.gematik.ti.erp.app.di.ApplicationPreferencesTag import de.gematik.ti.erp.app.mainscreen.ui.MainScreen -import de.gematik.ti.erp.app.tracking.Tracker +import de.gematik.ti.erp.app.mainscreen.ui.MainScreenViewModel +import de.gematik.ti.erp.app.mainscreen.ui.RedeemStateViewModel +import de.gematik.ti.erp.app.orderhealthcard.ui.HealthCardOrderViewModel +import de.gematik.ti.erp.app.pharmacy.ui.PharmacySearchViewModel +import de.gematik.ti.erp.app.prescription.detail.ui.PrescriptionDetailsViewModel +import de.gematik.ti.erp.app.prescription.ui.PrescriptionViewModel +import de.gematik.ti.erp.app.prescription.ui.ScanPrescriptionViewModel +import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel +import de.gematik.ti.erp.app.profiles.ui.ProfileViewModel +import de.gematik.ti.erp.app.redeem.ui.RedeemViewModel +import de.gematik.ti.erp.app.settings.ui.SettingsViewModel +import de.gematik.ti.erp.app.analytics.Analytics +import de.gematik.ti.erp.app.apicheck.usecase.CheckVersionUseCase +import de.gematik.ti.erp.app.cardwall.mini.ui.SecureHardwarePrompt +import de.gematik.ti.erp.app.core.IntentHandler +import de.gematik.ti.erp.app.core.LocalIntentHandler +import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyOverviewViewModel +import de.gematik.ti.erp.app.prescription.detail.ui.SharePrescriptionHandler +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.rememberProfileHandler import de.gematik.ti.erp.app.userauthentication.ui.AuthenticationModeAndMethod import de.gematik.ti.erp.app.userauthentication.ui.AuthenticationUseCase import de.gematik.ti.erp.app.userauthentication.ui.UserAuthenticationScreen +import de.gematik.ti.erp.app.userauthentication.ui.UserAuthenticationViewModel +import de.gematik.ti.erp.app.utils.compose.DebugOverlay import de.gematik.ti.erp.app.utils.compose.DialogHost -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import javax.inject.Inject +import org.kodein.di.Copy +import org.kodein.di.DIAware +import org.kodein.di.android.closestDI +import org.kodein.di.android.retainedSubDI +import org.kodein.di.bindProvider +import org.kodein.di.bindSingleton +import org.kodein.di.compose.rememberViewModel +import org.kodein.di.compose.withDI +import org.kodein.di.instance -const val SCREENSHOTS_ALLOWED = "SCREENSHOTS_ALLOWED" +const val ScreenshotsAllowed = "SCREENSHOTS_ALLOWED" -@AndroidEntryPoint -class MainActivity : AppCompatActivity() { - @Inject - lateinit var auth: AuthenticationUseCase +class NfcNotEnabledException : IllegalStateException() - @Inject - lateinit var navigationObservable: NavigationObservable +class MainActivity : AppCompatActivity(), DIAware { + override val di by retainedSubDI(closestDI(), copy = Copy.None) { + if (BuildKonfig.INTERNAL) { + fullContainerTreeOnError = true + } + + bindProvider { UnlockEgkViewModel(instance(), instance()) } + bindProvider { MiniCardWallViewModel(instance(), instance(), instance(), instance(), instance()) } + bindProvider { CardWallNfcPositionViewModel(instance()) } + bindProvider { CardWallViewModel(instance(), instance(), instance()) } + bindProvider { ExternalAuthenticatorListViewModel(instance(), instance()) } + bindProvider { RedeemStateViewModel(instance(), instance()) } + bindProvider { HealthCardOrderViewModel(instance()) } + bindProvider { PrescriptionDetailsViewModel(instance(), instance()) } + bindProvider { PrescriptionViewModel(instance(), instance(), instance()) } + bindProvider { + ScanPrescriptionViewModel( + prescriptionUseCase = instance(), + profilesUseCase = instance(), + scanner = instance(), + processor = instance(), + validator = instance(), + dispatchers = instance() + ) + } + bindProvider { ProfileViewModel(instance()) } + bindProvider { ProfileSettingsViewModel(instance(), instance()) } + bindProvider { RedeemViewModel(instance(), instance(), instance()) } + bindProvider { UserAuthenticationViewModel(instance()) } + bindProvider { PharmacySearchViewModel(instance(), instance(), instance(), instance()) } + bindProvider { PharmacyOverviewViewModel(instance()) } + + bindSingleton { + SettingsViewModel( + settingsUseCase = instance(), + profilesUseCase = instance(), + profilesWithPairedDevicesUseCase = instance(), + analytics = instance(), + appPrefs = instance(ApplicationPreferencesTag), + dispatchers = instance() + ) + } + bindSingleton { MainViewModel(instance(), instance()) } + bindSingleton { MainScreenViewModel(instance(), instance()) } + + bindProvider { CheckVersionUseCase(instance(), instance()) } - @Inject - lateinit var tracker: Tracker + if (BuildConfig.DEBUG && BuildKonfig.INTERNAL) { + bindSingleton { TestWrapper(instance(), instance(), instance(), instance()) } + } + } + + private val checkVersionUseCase: CheckVersionUseCase by instance() + + private val auth: AuthenticationUseCase by instance() - @Inject - @ApplicationPreferences - lateinit var appPrefs: SharedPreferences + private val analytics: Analytics by instance() + + private val appPrefs: SharedPreferences by instance(ApplicationPreferencesTag) + + private val intentHandler = IntentHandler(this) private val _nfcTag = MutableSharedFlow() val nfcTagFlow: Flow - get() = _nfcTag + get() = _nfcTag.onStart { + if (!NfcAdapter.getDefaultAdapter(this@MainActivity).isEnabled) { + throw NfcNotEnabledException() + } + } - private val authenticationModeAndMethod + private val authenticationModeAndMethod: Flow get() = auth.authenticationModeAndMethod - @OptIn(ExperimentalAnimationApi::class) + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + val testWrapper: TestWrapper by instance() + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @Stable + class Element( + val bounds: Rect, + val tag: String + ) + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + val elements: SnapshotStateMap = mutableStateMapOf() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launchWhenCreated { + intent?.let { + intentHandler.propagateIntent(it) + } + } + + lifecycleScope.launchWhenResumed { + checkAppUpdate() + } + + if (!BuildConfig.DEBUG) { + installMessageConversionExceptionHandler() + } + if (BuildKonfig.INTERNAL) { appPrefs.edit { - putBoolean(SCREENSHOTS_ALLOWED, true) + putBoolean(ScreenshotsAllowed, true) } } switchScreenshotMode() appPrefs.registerOnSharedPreferenceChangeListener { _, key -> - if (key == SCREENSHOTS_ALLOWED) { + if (key == ScreenshotsAllowed) { switchScreenshotMode() } } @@ -105,49 +235,141 @@ class MainActivity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - CompositionLocalProvider( - LocalActivity provides this, - LocalTracker provides tracker - ) { - MainContent { mainViewModel -> - val auth by authenticationModeAndMethod.collectAsState(null) - val navController = rememberNavController() - val noDrawModifier = Modifier.fillMaxSize().graphicsLayer(alpha = 0f) - - mainViewModel.externalAuthorizationUri = intent.data - - Box(modifier = Modifier.fillMaxSize()) { - if (auth !is AuthenticationModeAndMethod.Authenticated) { - Image( - painterResource(R.drawable.erp_logo), - null, - modifier = Modifier.align(Alignment.Center) - ) - } + val view = LocalView.current + LaunchedEffect(view) { + ViewCompat.setWindowInsetsAnimationCallback(view, null) + } + + withDI(di) { + CompositionLocalProvider( + LocalActivity provides this, + LocalAnalytics provides analytics, + LocalIntentHandler provides intentHandler, + LocalAuthenticator provides rememberAuthenticator(intentHandler) + ) { + val authenticator = LocalAuthenticator.current - DialogHost { - Box( - if (auth is AuthenticationModeAndMethod.Authenticated) Modifier else noDrawModifier - ) { - MainScreen(navController, mainViewModel) + MainContent { mainViewModel -> + val auth by produceState(null) { + launch { + authenticationModeAndMethod.distinctUntilChangedBy { it::class } + .collect { + if (it is AuthenticationModeAndMethod.AuthenticationRequired) { + authenticator.cancelAllAuthentications() + } + } + } + authenticationModeAndMethod.collect { + value = it } } + val navController = rememberNavController() + val noDrawModifier = Modifier + .fillMaxSize() + .graphicsLayer(alpha = 0f) + + Box(modifier = Modifier.fillMaxSize()) { + if (auth !is AuthenticationModeAndMethod.Authenticated) { + Image( + painterResource(R.drawable.erp_logo), + null, + modifier = Modifier.align(Alignment.Center) + ) + } + + DialogHost { + Box( + if (auth is AuthenticationModeAndMethod.Authenticated) Modifier else noDrawModifier + ) { + // mini card wall + HealthCardPrompt( + authenticator = authenticator.authenticatorHealthCard + ) + ExternalAuthPrompt( + authenticator = authenticator.authenticatorExternal + ) + SecureHardwarePrompt( + authenticator = authenticator.authenticatorSecureElement + ) + + val settingsViewModel by rememberViewModel() + val profileSettingsViewModel by rememberViewModel() + val mainScreenViewModel by rememberViewModel() + + CompositionLocalProvider( + LocalProfileHandler provides rememberProfileHandler() + ) { + MainScreen( + navController = navController, + mainViewModel = mainViewModel, + settingsViewModel = settingsViewModel, + mainScreenViewModel = mainScreenViewModel, + profileSettingsViewModel = profileSettingsViewModel + ) + + SharePrescriptionHandler(authenticationModeAndMethod) + } + } + } - DialogHost { - AnimatedVisibility( - visible = auth is AuthenticationModeAndMethod.AuthenticationRequired, - enter = fadeIn(), - exit = fadeOut() - ) { - UserAuthenticationScreen() + DialogHost { + AnimatedVisibility( + visible = auth is AuthenticationModeAndMethod.AuthenticationRequired, + enter = fadeIn(), + exit = fadeOut() + ) { + UserAuthenticationScreen() + } } } } + if (BuildConfig.DEBUG && BuildKonfig.DEBUG_VISUAL_TEST_TAGS) { + DebugOverlay(elements) + } } } } } + private suspend fun checkAppUpdate() { + if (checkVersionUseCase.isUpdateRequired()) { + val appUpdateManager = AppUpdateManagerFactory.create(this) + val appUpdateInfoTask = appUpdateManager.appUpdateInfo + + appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + val task = appUpdateManager.startUpdateFlow( + appUpdateInfo, + this, + AppUpdateOptions.defaultOptions(IMMEDIATE) + ) + + task.addOnCompleteListener { + if (task.isSuccessful && task.result != Activity.RESULT_OK) { + finish() + } + } + } + } + } + } + + override fun onUserInteraction() { + super.onUserInteraction() + + auth.resetInactivityTimer() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + lifecycleScope.launch { + intent?.let { + intentHandler.propagateIntent(it) + } + } + } + override fun onResume() { super.onResume() @@ -156,7 +378,9 @@ class MainActivity : AppCompatActivity() { it.enableReaderMode( this, ::onTagDiscovered, - NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, + NfcAdapter.FLAG_READER_NFC_A + or NfcAdapter.FLAG_READER_NFC_B + or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, Bundle() ) } @@ -169,7 +393,6 @@ class MainActivity : AppCompatActivity() { } } - @OptIn(ExperimentalCoroutinesApi::class) override fun onPause() { super.onPause() @@ -178,7 +401,7 @@ class MainActivity : AppCompatActivity() { private fun switchScreenshotMode() { // `gemSpec_eRp_FdV A_20203` default settings are not allow screenshots - if (appPrefs.getBoolean(SCREENSHOTS_ALLOWED, false)) { + if (appPrefs.getBoolean(ScreenshotsAllowed, false)) { this.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } else { this.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) diff --git a/android/src/main/java/de/gematik/ti/erp/app/MessageConversionException.kt b/android/src/main/java/de/gematik/ti/erp/app/MessageConversionException.kt new file mode 100644 index 00000000..aca9ef1b --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/MessageConversionException.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app + +import org.jose4j.base64url.Base64Url + +class MessageConversionException(private val throwable: Throwable) : Throwable(cause = throwable) { + override fun toString(): String { + val name = throwable.javaClass.name + val message = throwable.localizedMessage + + return if (message != null) { + val msgBase64 = Base64Url + .encodeUtf8ByteRepresentation(message) + .replace('-', '$') // class names don't contain any minus symbol + + "${name}_$msgBase64: $message" + } else { + name + } + } +} + +fun installMessageConversionExceptionHandler() { + val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + defaultExceptionHandler!!.uncaughtException(thread, MessageConversionException(throwable)) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/Navigation.kt b/android/src/main/java/de/gematik/ti/erp/app/Navigation.kt index b62776ec..b4f56911 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/Navigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/Navigation.kt @@ -21,11 +21,13 @@ package de.gematik.ti.erp.app import android.net.Uri import android.os.Bundle import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.navigation.NamedNavArgument import androidx.navigation.NavType -import com.squareup.moshi.Moshi import de.gematik.ti.erp.app.mainscreen.ui.TaskIds -import javax.annotation.concurrent.Immutable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json abstract class UriNavType(override val isNullableAllowed: Boolean) : NavType(isNullableAllowed) { @@ -34,8 +36,6 @@ abstract class UriNavType(override val isNullableAllowed: Boolean) : object AppNavTypes { val TaskIdsType = object : UriNavType(false) { - private val moshi = Moshi.Builder().build().adapter(TaskIds::class.java) - override fun put(bundle: Bundle, key: String, value: TaskIds) { bundle.putParcelable(key, value) } @@ -45,11 +45,11 @@ object AppNavTypes { } override fun parseValue(value: String): TaskIds { - return moshi.fromJson(value)!! + return Json.decodeFromString(value) } override fun serializeValue(value: TaskIds): String { - return moshi.toJson(value)!! + return Json.encodeToString(value) } override val name: String diff --git a/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt b/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt new file mode 100644 index 00000000..951339e2 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app + +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import kotlin.properties.ReadOnlyProperty + +/** + * Returns the qualified name of the property without the package and `TestTag` prefix. + * Example: + * ``` + * object TestTag { + * object Onboarding { + * val WelcomePage by tagName() + * ... + * ``` + * will be `Onboarding.WelcomePage`. + */ +fun tagName(): ReadOnlyProperty = + ReadOnlyProperty { thisRef, property -> + "${thisRef!!::class.qualifiedName!!.removePrefix("de.gematik.ti.erp.app.TestTag.")}.${property.name}" + } + +fun testDataPropertyKey(name: String) = + SemanticsPropertyKey( + name = name, + mergePolicy = { parentValue, _ -> + parentValue + } + ) + +val PrescriptionId = testDataPropertyKey(name = "PrescriptionId") +var SemanticsPropertyReceiver.prescriptionId by PrescriptionId + +val PrescriptionIds = testDataPropertyKey>(name = "PrescriptionIds") +var SemanticsPropertyReceiver.prescriptionIds by PrescriptionIds + +val PharmacyId = testDataPropertyKey(name = "PharmacyId") +var SemanticsPropertyReceiver.pharmacyId by PharmacyId + +val InsuranceState = testDataPropertyKey(name = "InsuranceState") +var SemanticsPropertyReceiver.insuranceState by InsuranceState + +val SubstitutionAllowed = testDataPropertyKey(name = "SubstitutionAllowed") +var SemanticsPropertyReceiver.substitutionAllowed by SubstitutionAllowed + +val SupplyForm = testDataPropertyKey(name = "SupplyForm") +var SemanticsPropertyReceiver.supplyForm by SupplyForm + +val MedicationCategory = testDataPropertyKey(name = "MedicationCategory") +var SemanticsPropertyReceiver.medicationCategory by MedicationCategory + +// Test tags for debug builds. +// +// Read before modifying! +// +// Developers: Use `@Deprecated(...)` for unused/old tags and always create a new tag with the by delegate `tagName()`. +// Testers: Replace `by tagName()` with an expressive name, e.g. `= "SomeName"`. `@Deprecated` identifiers are not used +// anymore and should be replaced according their info. +object TestTag { + // ...Screen = Scaffold + // ...Content = LazyColumn + + object TopNavigation { + val BackButton by tagName() + val CloseButton by tagName() + } + + object Prescriptions { + val Content by tagName() + val FullDetailPrescription by tagName() + val FullDetailPrescriptionName by tagName() + + object Details { + val Content by tagName() + val Screen by tagName() + + val MoreButton by tagName() + val DeleteButton by tagName() + + val MedicationButton by tagName() + val PrescriberButton by tagName() + val PatientButton by tagName() + val OrganizationButton by tagName() + val TechnicalInformationButton by tagName() + + object TechnicalInformation { + val Content by tagName() + val Screen by tagName() + + val AccessCode by tagName() + val TaskId by tagName() + } + + object Patient { + val Content by tagName() + val Screen by tagName() + + val BirthDate by tagName() + val Name by tagName() + val KVNR by tagName() + val Address by tagName() + val InsuranceName by tagName() + val InsuranceState by tagName() + } + + object Practitioner { + val Content by tagName() + val Screen by tagName() + + val Name by tagName() + val Type by tagName() + val LANR by tagName() + } + + object Organization { + val Content by tagName() + val Screen by tagName() + + val Name by tagName() + val Address by tagName() + val BSNR by tagName() + val Phone by tagName() + val EMail by tagName() + } + + object Medication { + val Content by tagName() + val Screen by tagName() + + val Name by tagName() + val Amount by tagName() + val PZN by tagName() + val SupplyForm by tagName() + val Category by tagName() + val DosageInstruction by tagName() + val FreeText by tagName() + val BVG by tagName() + + val StandardSize by tagName() + val SubstitutionAllowed by tagName() + val Type by tagName() + } + } + } + + object PharmacySearch { + val OverviewContent by tagName() + val OverviewScreen by tagName() + val ResultContent by tagName() + val ResultScreen by tagName() + + val TextSearchButton by tagName() + val TextSearchField by tagName() + val PharmacyListEntry by tagName() + + object OrderOptions { + val Content by tagName() + val Screen by tagName() + + val PickUpOptionButton by tagName() + val CourierDeliveryOptionButton by tagName() + val OnlineDeliveryOptionButton by tagName() + } + + object OrderSummary { + val Content by tagName() + val Screen by tagName() + + val PrescriptionListItem by tagName() + + val SendOrderButton by tagName() + } + } + + object Orders { + val Content by tagName() + + val OrderListItem by tagName() + + object Details { + val Content by tagName() + val Screen by tagName() + + val PrescriptionListItem by tagName() + } + } + + object Settings { + val SettingsScreen by tagName() + val DebugMenuButton by tagName() + val ProfileButton by tagName() + val AddProfileButton by tagName() + + val OrderNewCardButton by tagName() + + object AddProfileDialog { + val Modal by tagName() + val ProfileNameTextField by tagName() + val ConfirmButton by tagName() + val CancelButton by tagName() + } + + object OrderEgk { + val OrderEgkScreen by tagName() + val OrderEgkContent by tagName() + val NFCExplanationPageLink by tagName() + val ChooseInsuranceButton by tagName() + } + + object ContactInsuranceCompany { + val OrderEgkAndPinRadioButton by tagName() + val OrderPinRadioButton by tagName() + val TelephoneButton by tagName() + val WebsiteButton by tagName() + val MailToButton by tagName() + val NoContactInfoTextBox by tagName() + } + + object InsuranceCompanyList { + val InsuranceSelectionScreen by tagName() + val InsuranceSelectionContent by tagName() + val ListOfInsuranceButtons by tagName() + } + } + + object AlertDialog { + val Modal by tagName() + val ConfirmButton by tagName() + val CancelButton by tagName() + } + + object DebugMenu { + val DebugMenuScreen by tagName() + val DebugMenuContent by tagName() + val CertificateField by tagName() + val PrivateKeyField by tagName() + val SetVirtualHealthCardButton by tagName() + } + + object BottomNavigation { + val PrescriptionButton by tagName() + val OrdersButton by tagName() + val PharmaciesButton by tagName() + val SettingsButton by tagName() + } + + object MainScreenBottomSheet { + val ConnectLaterButton by tagName() + val ConnectButton by tagName() + } + + object Onboarding { + val Pager by tagName() + val SkipOnboardingButton by tagName() + val NextButton by tagName() + val ScreenContent by tagName() + + val WelcomeScreen by tagName() + + val CredentialsScreen by tagName() + + object Credentials { + val BiometricTab by tagName() + val PasswordTab by tagName() + val PasswordFieldA by tagName() + val PasswordFieldB by tagName() + val PasswordStrengthCheck by tagName() + } + + val AnalyticsScreen by tagName() + val DataTermsScreen by tagName() + + object DataTerms { + val AcceptDataTermsSwitch by tagName() + val OpenTermsOfUseButton by tagName() + val OpenDataProtectionButton by tagName() + } + + val DataProtectionScreen by tagName() + val TermsOfUseScreen by tagName() + + val AnalyticsSwitch by tagName() + + object Analytics { + val ScreenContent by tagName() + val AcceptAnalyticsButton by tagName() + } + } + + object Main { + val MainScreen by tagName() + val LoginButton by tagName() + val CenterScreenMessageField by tagName() + + object Profile { + val OpenProfileListButton by tagName() + val ProfileDetailsButton by tagName() + } + + object OrderSuccessDialog { + val Modal by tagName() + val DismissButton by tagName() + } + } + + object Profile { + val ProfileScreen by tagName() + val ProfileScreenContent by tagName() + val OpenTokensScreenButton by tagName() + val InsuranceId by tagName() + val LoginButton by tagName() + val ThreeDotMenuButton by tagName() + val LogoutButton by tagName() + val DeleteProfileButton by tagName() + val EditProfileNameButton by tagName() + val EditProfileImageButton by tagName() + val NewProfileNameField by tagName() + + object EditProfileIcon { + val ColorSelectorSpringGrayButton by tagName() + val ColorSelectorSunDewButton by tagName() + val ColorSelectorPinkButton by tagName() + val ColorSelectorTreeButton by tagName() + val ColorSelectorBlueMoonButton by tagName() + } + + val OpenAuditEventsScreenButton by tagName() + + object TokenList { + val TokenScreen by tagName() + val AccessToken by tagName() + val SSOToken by tagName() + val NoTokenHeader by tagName() + val NoTokenInfo by tagName() + } + + object AuditEvents { + val AuditEventsScreen by tagName() + val NoAuditEventHeader by tagName() + val NoAuditEventInfo by tagName() + val AuditEvent by tagName() + } + } + + object CardWall { + val ContinueButton by tagName() + + object Login { + val LoginScreen by tagName() + } + + object CAN { + val CANField by tagName() + } + + object PIN { + val PINField by tagName() + } + + object StoreCredentials { + val Save by tagName() + val DontSave by tagName() + } + + object SecurityAcceptance { + val AcceptButton by tagName() + } + + object Nfc { + val NfcScreen by tagName() + val CardReadingDialog by tagName() + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/TestWrapper.kt b/android/src/main/java/de/gematik/ti/erp/app/TestWrapper.kt new file mode 100644 index 00000000..5714c173 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/TestWrapper.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app + +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.prescription.repository.LocalDataSource +import de.gematik.ti.erp.app.prescription.repository.RemoteDataSource +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.spec.ECPrivateKeySpec +import org.bouncycastle.util.encoders.Base64 +import org.jose4j.jws.EcdsaUsingShaAlgorithm +import java.math.BigInteger +import java.security.KeyFactory +import java.security.Signature + +private const val SignatureOutputSize = 64 + +class TestWrapper( + private val profilesUseCase: ProfilesUseCase, + private val remoteDataSource: RemoteDataSource, + private val localDataSource: LocalDataSource, + private val idpUseCase: IdpUseCase +) { + init { + require(BuildKonfig.INTERNAL) + require(BuildConfig.DEBUG) + } + + fun deleteTask(taskId: String) = runBlocking(Dispatchers.IO) { + remoteDataSource.deleteTask(profilesUseCase.activeProfile.first().id, taskId) + } + + fun deleteAllTasksSafe() = runBlocking(Dispatchers.IO) { + val profileId = profilesUseCase.activeProfile.first().id + localDataSource.loadTaskIds().first().forEach { taskId -> + remoteDataSource.deleteTask(profileId, taskId) + } + } + + fun loginWithVirtualHealthCard( + certificateBase64: String = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE, + privateKeyBase64: String = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY + ) { + runBlocking(Dispatchers.IO) { + idpUseCase.authenticationFlowWithHealthCard( + profileId = profilesUseCase.activeProfileId().first(), + cardAccessNumber = "123123", + healthCardCertificate = { Base64.decode(certificateBase64) }, + sign = { + val curveSpec = ECNamedCurveTable.getParameterSpec("brainpoolP256r1") + val keySpec = + ECPrivateKeySpec(BigInteger(Base64.decode(privateKeyBase64)), curveSpec) + val privateKey = KeyFactory.getInstance("EC", BCProvider).generatePrivate(keySpec) + val signed = Signature.getInstance("NoneWithECDSA").apply { + initSign(privateKey) + update(it) + }.sign() + EcdsaUsingShaAlgorithm.convertDerToConcatenated(signed, SignatureOutputSize) + } + ) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/VisibleDebugTree.kt b/android/src/main/java/de/gematik/ti/erp/app/VisibleDebugTree.kt new file mode 100644 index 00000000..c4151540 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/VisibleDebugTree.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import io.github.aakira.napier.Antilog +import io.github.aakira.napier.LogLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +class VisibleDebugTree : Antilog() { + val rotatingLog = MutableStateFlow>(emptyList()) + + override fun performLog(priority: LogLevel, tag: String?, throwable: Throwable?, message: String?) { + rotatingLog.update { + if (it.size > 500) { + it.drop(10) + } else { + it + } + buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(tag ?: "unknown") + } + append(" ") + if (priority == LogLevel.ERROR) { + withStyle(SpanStyle(color = Color.Red)) { + append(message ?: "") + } + } else { + append(message ?: "") + } + throwable?.run { + withStyle(SpanStyle(color = Color.Red)) { + append(throwable.message ?: "") + } + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt b/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt new file mode 100644 index 00000000..77b956ab --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/analytics/Analytics.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.analytics + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.core.content.edit +import androidx.navigation.NavHostController +import de.gematik.ti.erp.app.core.LocalAnalytics +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import io.github.aakira.napier.Napier +import java.security.MessageDigest + +private const val TrackerName = "Tracker" + +// `gemSpec_eRp_FdV A_20187` +class Analytics constructor( + private val context: Context +) { + private val _trackingAllowed = MutableStateFlow(false) + val trackingAllowed: StateFlow + get() = _trackingAllowed + + private val prefsName = "pro.piwik.sdk_" + + MessageDigest.getInstance("MD5").digest(TrackerName.toByteArray()) + .joinToString(separator = "") { eachByte -> "%02X".format(eachByte) } + + init { + Napier.d("Init tracker") + + _trackingAllowed.value = !context.getSharedPreferences( + prefsName, + Context.MODE_PRIVATE + ).getBoolean("tracker.optout", true) + } + + fun allowTracking() { + _trackingAllowed.value = true + + context.getSharedPreferences( + prefsName, + Context.MODE_PRIVATE + ).let { prefs -> + prefs.edit { + putBoolean("tracker.optout", false) + } + } + + Napier.d("Tracking allowed") + } + + fun disallowTracking() { + _trackingAllowed.value = false + + context.getSharedPreferences( + prefsName, + Context.MODE_PRIVATE + ).let { prefs -> + prefs.edit { + putBoolean("tracker.optout", true) + } + } + + Napier.d("Tracking disallowed") + } + + @Suppress("UnusedPrivateMember") + fun trackScreen(path: String) { + // noop + } + + fun trackIdentifiedWithIDP() { + // noop + } + + enum class AuthenticationProblem { + CardBlocked, + CardAccessNumberWrong, + CardCommunicationInterrupted, + CardPinWrong, + IDPCommunicationFailed, + IDPCommunicationInvalidCertificate, + IDPCommunicationInvalidOCSPOfCard, + SecureElementCryptographyFailed, + UserNotAuthenticated + } + + @Suppress("UnusedPrivateMember") + fun trackAuthenticationProblem(kind: AuthenticationProblem) { + // noop + } + + fun trackSaveScannedPrescriptions() { + // noop + } +} + +@Composable +fun TrackNavigationChanges(navController: NavHostController) { + val tracker = LocalAnalytics.current + + LaunchedEffect(Unit) { + navController.currentBackStackEntryFlow.collect { + try { + tracker.trackScreen(Uri.parse(it.destination.route).buildUpon().clearQuery().build().toString()) + } catch (expected: Exception) { + Napier.e("Couldn't track navigation screen", expected) + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/api/FhirConverterFactory.kt b/android/src/main/java/de/gematik/ti/erp/app/api/FhirConverterFactory.kt deleted file mode 100644 index 0086136f..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/api/FhirConverterFactory.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.api - -import ca.uhn.fhir.parser.IParser -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.ResponseBody -import org.hl7.fhir.r4.model.Resource -import retrofit2.Converter -import retrofit2.Retrofit -import java.lang.reflect.Type - -class FhirConverterFactory(private val parser: IParser) : Converter.Factory() { - - companion object { - fun create(parser: IParser) = FhirConverterFactory(parser) - } - - override fun responseBodyConverter( - type: Type, - annotations: Array, - retrofit: Retrofit - ): Converter { - return FhirBundleConverter(parser) - } - - override fun requestBodyConverter( - type: Type, - parameterAnnotations: Array, - methodAnnotations: Array, - retrofit: Retrofit - ): Converter { - return FhirResourceConverter(parser) - } - - class FhirBundleConverter(private val parser: IParser) : Converter { - - override fun convert(value: ResponseBody): Any? { - return parser.parseResource(value.byteStream()) - } - } - - class FhirResourceConverter(private val parser: IParser) : Converter { - - override fun convert(value: Resource): RequestBody { - val result = parser.setPrettyPrint(false).encodeResourceToString(value) - // TODO Remove this replace as soon as the spec is updated and the backend handles the accessCode itself - return result - .replace("\$accept", "/\$accept") - .toRequestBody("application/fhir+json".toMediaType()) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/api/Result.kt b/android/src/main/java/de/gematik/ti/erp/app/api/Result.kt deleted file mode 100644 index 7196be47..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/api/Result.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.api - -import timber.log.Timber - -sealed class Result { - - class Success(data: T?) : Result() { - private val _data = data - val data - get() = requireNotNull(_data) - } - - class Error(val exception: Exception) : Result() { - init { - Timber.d(exception, "/\\/\\//\\//\\//\\//\\//\\//\\//\\//\\//\\//\\//\\//\\//\\//\\//\\\n") - } - } - - override fun toString(): String { - return when (this) { - is Success<*> -> "Success[data=$data]" - is Error -> "Error[exception=$exception]" - } - } -} - -inline fun Result.map(block: (A) -> Result): Result = - when (this) { - is Result.Success -> block(this.data) - is Result.Error -> this - } - -inline fun Result.mapCatching(block: (A) -> Result): Result = - when (this) { - is Result.Success -> try { - block(this.data) - } catch (e: Exception) { - Result.Error(e) - } - is Result.Error -> this - } - -/** - * Calls [block] only if *all* results are successful. - */ -inline fun List>.mapSuccessful(block: (List) -> Result): Result = - this.find { it is Result.Error } as? Result.Error - ?: block(this.map { (it as Result.Success).data }) - -inline fun Result.onSuccess(block: (A) -> Unit): Result = - when (this) { - is Result.Success -> { - block(this.data) - this - } - is Result.Error -> this - } - -inline fun List>.mapSuccessful(block: (A) -> Result): List> = - map { it.map(block) } - -fun Result.unwrap(): T = - when (this) { - is Result.Success -> this.data - is Result.Error -> throw this.exception - } diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/AttestationModule.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/AttestationModule.kt new file mode 100644 index 00000000..f839130f --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/AttestationModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.attestation + +import android.content.Context +import com.google.android.gms.safetynet.SafetyNet +import de.gematik.ti.erp.app.attestation.repository.AttestationLocalDataSource +import de.gematik.ti.erp.app.attestation.repository.AttestationRemoteDataSource +import de.gematik.ti.erp.app.attestation.repository.SafetynetAttestationRepository +import de.gematik.ti.erp.app.attestation.usecase.SafetynetUseCase +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +val attestationModule = DI.Module("attestationModule") { + bindSingleton { SafetyNet.getClient(instance()) } + + bindSingleton { SafetynetAttestation(instance(), instance()) } + bindSingleton { SafetyNetAttestationReportGenerator() } + bindSingleton { AttestationLocalDataSource(instance()) } + bindSingleton { AttestationRemoteDataSource(instance()) } + bindSingleton { SafetynetAttestationRepository(instance(), instance()) } + bindSingleton { SafetynetUseCase(instance(), instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetyNetAttestationReportGenerator.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetyNetAttestationReportGenerator.kt index 299ed140..fadff7ea 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetyNetAttestationReportGenerator.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetyNetAttestationReportGenerator.kt @@ -25,11 +25,10 @@ import org.bouncycastle.asn1.x500.style.IETFUtils import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder import org.jose4j.jwt.consumer.JwtConsumerBuilder import java.security.cert.X509Certificate -import javax.inject.Inject private const val HOSTNAME = "attest.android.com" -class SafetyNetAttestationReportGenerator @Inject constructor() : AttestationReportGenerator { +class SafetyNetAttestationReportGenerator : AttestationReportGenerator { override suspend fun convertToReport(jwt: String, nonce: ByteArray): Attestation.Report { val jws = JwtConsumerBuilder() diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetynetAttestation.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetynetAttestation.kt index fa21c4f5..ac10aa24 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetynetAttestation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/SafetynetAttestation.kt @@ -23,18 +23,16 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.safetynet.SafetyNetApi import com.google.android.gms.safetynet.SafetyNetClient -import dagger.hilt.android.qualifiers.ApplicationContext import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.attestation.AttestationException.AttestationExceptionType import kotlinx.coroutines.suspendCancellableCoroutine -import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException const val PLAY_SERVICES_VERSION = 13000000 -class SafetynetAttestation @Inject constructor( - @ApplicationContext val context: Context, +class SafetynetAttestation( + val context: Context, private val safetyNetClient: SafetyNetClient ) : Attestation { diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/model/AttestationData.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/model/AttestationData.kt new file mode 100644 index 00000000..522fb785 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/model/AttestationData.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.attestation.model + +object AttestationData { + data class SafetynetAttestation( + val jws: String, + val ourNonce: ByteArray + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationLocalDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationLocalDataSource.kt index 0201efa0..f69cc473 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationLocalDataSource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationLocalDataSource.kt @@ -18,19 +18,31 @@ package de.gematik.ti.erp.app.attestation.repository -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.SafetynetAttestationEntity +import de.gematik.ti.erp.app.attestation.model.AttestationData +import de.gematik.ti.erp.app.db.entities.v1.SafetynetAttestationEntityV1 +import de.gematik.ti.erp.app.db.writeOrCopyToRealm +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query import kotlinx.coroutines.flow.Flow -import javax.inject.Inject +import kotlinx.coroutines.flow.map -class AttestationLocalDataSource @Inject constructor( - private val db: AppDatabase +class AttestationLocalDataSource( + private val realm: Realm ) { - suspend fun persistReport(attestationEntity: SafetynetAttestationEntity) { - db.attestationDao().insertAttestation(attestationEntity) + suspend fun persistReport(attestation: AttestationData.SafetynetAttestation) { + realm.writeOrCopyToRealm(::SafetynetAttestationEntityV1) { + it.jws = attestation.jws + it.ourNonce = attestation.ourNonce + } } - fun fetchAttestations(): Flow> { - return db.attestationDao().getAllAttestations() - } + fun fetchAttestations(): Flow = + realm.query().first().asFlow().map { + it.obj?.let { + AttestationData.SafetynetAttestation( + jws = it.jws, + ourNonce = it.ourNonce + ) + } + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationRemoteDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationRemoteDataSource.kt index 98ea96c2..12e31f47 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationRemoteDataSource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/AttestationRemoteDataSource.kt @@ -19,9 +19,8 @@ package de.gematik.ti.erp.app.attestation.repository import de.gematik.ti.erp.app.attestation.Attestation -import javax.inject.Inject -class AttestationRemoteDataSource @Inject constructor( +class AttestationRemoteDataSource( private val safetynetAttestation: Attestation ) { suspend fun fetchAttestationReport(request: Attestation.Request) = diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/SafetynetAttestationRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/SafetynetAttestationRepository.kt index a3cbb5b0..26883e99 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/SafetynetAttestationRepository.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/repository/SafetynetAttestationRepository.kt @@ -19,17 +19,16 @@ package de.gematik.ti.erp.app.attestation.repository import de.gematik.ti.erp.app.attestation.Attestation -import de.gematik.ti.erp.app.db.entities.SafetynetAttestationEntity -import javax.inject.Inject +import de.gematik.ti.erp.app.attestation.model.AttestationData -class SafetynetAttestationRepository @Inject constructor( +class SafetynetAttestationRepository( private val localDataSource: AttestationLocalDataSource, private val remoteDataSource: AttestationRemoteDataSource ) { suspend fun fetchAttestationReportRemote(request: Attestation.Request) = remoteDataSource.fetchAttestationReport(request) - suspend fun persistAttestationReport(attestationEntity: SafetynetAttestationEntity) { + suspend fun persistAttestationReport(attestationEntity: AttestationData.SafetynetAttestation) { localDataSource.persistReport(attestationEntity) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCase.kt index 55416f62..ddb03dfc 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCase.kt @@ -25,34 +25,33 @@ import de.gematik.ti.erp.app.attestation.AttestationReportGenerator import de.gematik.ti.erp.app.attestation.SafetynetAttestationRequirements import de.gematik.ti.erp.app.attestation.SafetynetReport import de.gematik.ti.erp.app.attestation.SafetynetResult +import de.gematik.ti.erp.app.attestation.model.AttestationData import de.gematik.ti.erp.app.attestation.repository.SafetynetAttestationRepository -import de.gematik.ti.erp.app.db.entities.SafetynetAttestationEntity import de.gematik.ti.erp.app.secureRandomInstance import de.gematik.ti.erp.app.vau.toLowerCaseHex import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import timber.log.Timber +import io.github.aakira.napier.Napier import java.security.MessageDigest import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId -import javax.inject.Inject -class SafetynetUseCase @Inject constructor( +class SafetynetUseCase( private val repository: SafetynetAttestationRepository, private val attestationReportGenerator: AttestationReportGenerator, - private val dispatcher: DispatchProvider + private val dispatchers: DispatchProvider ) { fun runSafetynetAttestation() = repository.fetchAttestationsLocal().map { - withContext(dispatcher.io()) { - if (it.isEmpty()) { + withContext(dispatchers.IO) { + if (it == null) { fetchSafetynetResultRemoteAndPersist() true } else { - val attestationEntity = it[0] + val attestationEntity = it val safetynetReport = attestationReportGenerator.convertToReport( attestationEntity.jws, @@ -66,7 +65,7 @@ class SafetynetUseCase @Inject constructor( } } }.catch { exception -> - Timber.d("exception: ${exception.message}") + Napier.d("exception: ${exception.message}") emit(exception !is AttestationException) } @@ -77,9 +76,9 @@ class SafetynetUseCase @Inject constructor( val safetynetResult = repository.fetchAttestationReportRemote(request) as SafetynetResult repository.persistAttestationReport( - mapToAttestationEntity( - safetynetResult, - nonce + AttestationData.SafetynetAttestation( + jws = safetynetResult.jws, + ourNonce = nonce ) ) } @@ -99,13 +98,6 @@ class SafetynetUseCase @Inject constructor( return LocalDateTime.now().isAfter(validUntil) } - private fun mapToAttestationEntity(result: SafetynetResult, ourNonce: ByteArray) = - SafetynetAttestationEntity( - id = 0, - jws = result.jws, - ourNonce = ourNonce - ) - private fun provideSalt() = ByteArray(32).apply { secureRandomInstance().nextBytes(this) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/PsoAlgorithm.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/CardUnlockModule.kt similarity index 73% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/PsoAlgorithm.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardunlock/CardUnlockModule.kt index b5f94b7c..0b0df09f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/PsoAlgorithm.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/CardUnlockModule.kt @@ -16,13 +16,12 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.cardunlock -/** - * Represent a specific PSO Algorithm - * - * @see "ISO/IEC7816-4 und gemSpec_COS 'Spezifikation des Card Operating System'" - */ -enum class PsoAlgorithm(val identifier: Int) { - SIGN_VERIFY_ECDSA(0x00) +import de.gematik.ti.erp.app.cardunlock.usecase.UnlockEgkUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider + +val cardUnlockModule = DI.Module("cardUnlockModule") { + bindProvider { UnlockEgkUseCase() } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/model/UnlockEgkNavigation.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/model/UnlockEgkNavigation.kt new file mode 100644 index 00000000..d01b5de9 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/model/UnlockEgkNavigation.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardunlock.model + +import de.gematik.ti.erp.app.Route + +object UnlockEgkNavigation { + object Intro : Route("Intro") + object CardAccessNumber : Route("CardAccessNumber") + object PersonalUnblockingKey : Route("PersonalUnblockingKey") + object OldSecret : Route("OldSecret") + object NewSecret : Route("NewSecret") + object UnlockEgk : Route("UnlockEgk") + object TroubleshootingPageA : Route("TroubleshootingPageA") + object TroubleshootingPageB : Route("TroubleshootingPageB") + object TroubleshootingPageC : Route("TroubleshootingPageC") + object TroubleshootingNoSuccessPage : Route("TroubleshootingNoSuccessPage") +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEGKTroubleshooting.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEGKTroubleshooting.kt new file mode 100644 index 00000000..925baa70 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEGKTroubleshooting.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardunlock.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.cardwall.ui.TroubleshootingNoSuccessPageContent +import de.gematik.ti.erp.app.cardwall.ui.TroubleshootingPageAContent +import de.gematik.ti.erp.app.cardwall.ui.TroubleshootingPageBContent +import de.gematik.ti.erp.app.cardwall.ui.TroubleshootingPageCContent +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +@Composable +fun UnlockEGKTroubleshootingPageA( + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + unlockMethod: UnlockMethod, + oldSecret: String, + newSecret: String, + onRetryOldSecret: () -> Unit, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, + onAssignPin: () -> Unit +) { + val dialogState = rememberUnlockEgkDialogState() + UnlockEgkDialog( + dialogState = dialogState, + viewModel = viewModel, + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryOldSecret = onRetryOldSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock, + onAssignPin = onAssignPin + ) + val coroutineScope = rememberCoroutineScope() + TroubleshootingPageAContent( + onBack = onBack, + onNext = onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } + } + ) +} + +@Suppress("LongParameterList") +@Composable +fun UnlockEGKTroubleshootingPageB( + viewModel: UnlockEgkViewModel, + unlockMethod: UnlockMethod, + cardAccessNumber: String, + personalUnblockingKey: String, + oldSecret: String, + newSecret: String, + onRetryCan: () -> Unit, + onRetryOldSecret: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, + onAssignPin: () -> Unit +) { + val dialogState = rememberUnlockEgkDialogState() + UnlockEgkDialog( + dialogState = dialogState, + viewModel = viewModel, + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock, + onAssignPin = onAssignPin + ) + + val coroutineScope = rememberCoroutineScope() + TroubleshootingPageBContent( + onBack, + onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } + } + ) +} + +@Suppress("LongParameterList") +@Composable +fun UnlockEGKTroubleshootingPageC( + viewModel: UnlockEgkViewModel, + unlockMethod: UnlockMethod, + cardAccessNumber: String, + personalUnblockingKey: String, + oldSecret: String, + newSecret: String, + onRetryCan: () -> Unit, + onRetryOldSecret: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit, + onAssignPin: () -> Unit +) { + val dialogState = rememberUnlockEgkDialogState() + UnlockEgkDialog( + dialogState = dialogState, + viewModel = viewModel, + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock, + onAssignPin = onAssignPin + ) + + val coroutineScope = rememberCoroutineScope() + TroubleshootingPageCContent( + onBack, + onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } + } + ) +} + +@Composable +fun UnlockEGKTroubleshootingNoSuccessPage( + onNext: () -> Unit, + onBack: () -> Unit +) { + TroubleshootingNoSuccessPageContent( + onNext, + onBack + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt new file mode 100644 index 00000000..06e5421b --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt @@ -0,0 +1,669 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardunlock.ui + +import android.nfc.Tag +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.cardunlock.model.UnlockEgkNavigation +import de.gematik.ti.erp.app.cardwall.ui.CardAccessNumber +import de.gematik.ti.erp.app.cardwall.ui.CardHandlingScaffold +import de.gematik.ti.erp.app.cardwall.ui.CardWallNfcPositionViewModel +import de.gematik.ti.erp.app.cardwall.ui.ConformationSecretInputField +import de.gematik.ti.erp.app.cardwall.ui.NFCInstructionScreen +import de.gematik.ti.erp.app.cardwall.ui.SecretInputField +import de.gematik.ti.erp.app.pharmacy.ui.scrollOnFocus +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.HintCard +import de.gematik.ti.erp.app.utils.compose.HintSmallImage +import de.gematik.ti.erp.app.utils.compose.NavigationAnimation +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.NavigationMode +import de.gematik.ti.erp.app.utils.compose.SimpleCheck +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.SpacerTiny +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import org.kodein.di.compose.rememberViewModel + +const val SECRET_MIN_LENGTH = 6 +const val SECRET_MAX_LENGTH = 8 +const val ConfInputfieldPosition = 3 + +sealed class ToggleUnlock { + data class ToggleByUser(val value: Boolean) : ToggleUnlock() + data class ToggleByHealthCard(val tag: Tag) : ToggleUnlock() +} + +@Suppress("LongMethod") +@Composable +fun UnlockEgKScreen( + unlockMethod: UnlockMethod, + navController: NavController, + onClickLearnMore: () -> Unit +) { + val viewModel by rememberViewModel() + var unlockMethod by rememberSaveable { mutableStateOf(unlockMethod) } + + val unlockNavController = rememberNavController() + var cardAccessNumber by rememberSaveable { mutableStateOf("") } + var personalUnblockingKey by rememberSaveable { mutableStateOf("") } + var oldSecret by rememberSaveable { mutableStateOf("") } + var newSecret by rememberSaveable { mutableStateOf("") } + + val onRetryCan = { + unlockNavController.navigate(UnlockEgkNavigation.CardAccessNumber.path()) { + popUpTo(UnlockEgkNavigation.CardAccessNumber.path()) { inclusive = true } + } + } + + val onRetryOldSecret = { + unlockNavController.navigate(UnlockEgkNavigation.OldSecret.path()) { + popUpTo(UnlockEgkNavigation.OldSecret.path()) { inclusive = true } + } + } + + val onRetryPuk = { + unlockNavController.navigate(UnlockEgkNavigation.PersonalUnblockingKey.path()) { + popUpTo(UnlockEgkNavigation.PersonalUnblockingKey.path()) { inclusive = true } + } + } + + val onAssignPin = { + unlockMethod = UnlockMethod.ChangeReferenceData + unlockNavController.navigate(UnlockEgkNavigation.Intro.path()) { + popUpTo(UnlockEgkNavigation.Intro.path()) { inclusive = true } + } + } + + val onClose: () -> Unit = { navController.popBackStack() } + val onBack: () -> Unit = { unlockNavController.popBackStack() } + + NavHost( + unlockNavController, + startDestination = UnlockEgkNavigation.Intro.path() + ) { + composable(UnlockEgkNavigation.Intro.route) { + NavigationAnimation { + IntroScreen(unlockMethod = unlockMethod) { + unlockNavController.navigate(UnlockEgkNavigation.CardAccessNumber.path()) + } + } + } + composable(UnlockEgkNavigation.CardAccessNumber.route) { + NavigationAnimation { + CardAccessNumberScreen( + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + onCanChanged = { cardAccessNumber = it }, + onClickLearnMore = { onClickLearnMore() }, + onCancel = onClose + ) { + if (unlockMethod == UnlockMethod.ChangeReferenceData) { + unlockNavController.navigate(UnlockEgkNavigation.OldSecret.path()) + } else { + unlockNavController.navigate(UnlockEgkNavigation.PersonalUnblockingKey.path()) + } + } + } + } + + composable(UnlockEgkNavigation.PersonalUnblockingKey.route) { + NavigationAnimation { + PersonalUnblockingKeyScreen( + personalUnblockingKey = personalUnblockingKey, + unlockMethod = unlockMethod, + onPersonalUnblockingKeyChanged = { personalUnblockingKey = it }, + onCancel = onClose + ) { + if (unlockMethod == UnlockMethod.ResetRetryCounterWithNewSecret) { + unlockNavController.navigate(UnlockEgkNavigation.NewSecret.path()) + } else { + unlockNavController.navigate(UnlockEgkNavigation.UnlockEgk.path()) + } + } + } + } + + composable(UnlockEgkNavigation.OldSecret.route) { + NavigationAnimation { + OldSecretScreen( + oldSecret = oldSecret, + onSecretChange = { oldSecret = it }, + onCancel = onClose + ) { + unlockNavController.navigate(UnlockEgkNavigation.NewSecret.path()) + } + } + } + + composable(UnlockEgkNavigation.NewSecret.route) { + NavigationAnimation { + NewSecretScreen( + newSecret = newSecret, + onSecretChange = { newSecret = it }, + onCancel = onClose + ) { + unlockNavController.navigate(UnlockEgkNavigation.UnlockEgk.path()) + } + } + } + + composable(UnlockEgkNavigation.UnlockEgk.route) { + NavigationAnimation { + UnlockScreen( + unlockMethod = unlockMethod, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onBack = onBack, + onClickTroubleshooting = { + unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingPageA.path()) + }, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onAssignPin = onAssignPin + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingPageA.route) { + NavigationAnimation { + UnlockEGKTroubleshootingPageA( + viewModel = viewModel, + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onAssignPin = onAssignPin, + onNext = { unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingPageB.path()) }, + onBack = onBack + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingPageB.route) { + NavigationAnimation { + UnlockEGKTroubleshootingPageB( + viewModel = viewModel, + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onAssignPin = onAssignPin, + onNext = { unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingPageC.path()) }, + onBack = onBack + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingPageC.route) { + NavigationAnimation { + UnlockEGKTroubleshootingPageC( + viewModel = viewModel, + unlockMethod = unlockMethod, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onAssignPin = onAssignPin, + onNext = { unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingNoSuccessPage.path()) }, + onBack = onBack + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingNoSuccessPage.route) { + NavigationAnimation { + UnlockEGKTroubleshootingNoSuccessPage( + onNext = onClose, + onBack = onBack + ) + } + } + } +} + +@Composable +private fun IntroScreen(unlockMethod: UnlockMethod, onNext: () -> Unit) { + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("unlockEgk/unlock"), + title = when (unlockMethod) { + UnlockMethod.ChangeReferenceData -> { + stringResource(R.string.unlock_egk_top_bar_title_change_secret) + } + UnlockMethod.ResetRetryCounterWithNewSecret -> { + stringResource(R.string.unlock_egk_top_bar_title_forgot_pin) + } + else -> { + stringResource(R.string.unlock_egk_top_bar_title) + } + }, + onNext = onNext, + nextText = stringResource(R.string.unlock_egk_next), + listState = lazyListState + ) { + UnlockIntroContent(unlockMethod, lazyListState = lazyListState) + } +} + +@Composable +private fun UnlockIntroContent( + unlockMethod: UnlockMethod, + lazyListState: LazyListState +) { + LazyColumn( + state = lazyListState, + modifier = Modifier.padding(PaddingDefaults.Medium) + ) { + item { + Text( + text = stringResource(R.string.unlock_egk_intro_what_you_need), + style = AppTheme.typography.h5 + ) + SpacerLarge() + SimpleCheck(stringResource(R.string.unlock_egk_intro_egk)) + if (unlockMethod == UnlockMethod.ChangeReferenceData) { + SimpleCheck(stringResource(R.string.unlock_egk_intro_pin)) + SpacerSmall() + Text( + text = stringResource(R.string.cdw_pin_info), + style = AppTheme.typography.caption1l + ) + } else { + SimpleCheck(stringResource(R.string.unlock_egk_intro_puk)) + SpacerSmall() + Text( + text = stringResource(R.string.unlock_egk_puk_info), + style = AppTheme.typography.caption1l + ) + } + } + } +} + +@Composable +private fun CardAccessNumberScreen( + unlockMethod: UnlockMethod, + cardAccessNumber: String, + onCanChanged: (String) -> Unit, + onClickLearnMore: () -> Unit, + onCancel: () -> Unit, + onNext: () -> Unit +) { + CardAccessNumber( + onClickLearnMore = onClickLearnMore, + can = cardAccessNumber, + screenTitle = when (unlockMethod) { + UnlockMethod.ChangeReferenceData -> { + stringResource(R.string.unlock_egk_top_bar_title_change_secret) + } + UnlockMethod.ResetRetryCounterWithNewSecret -> { + stringResource(R.string.unlock_egk_top_bar_title_forgot_pin) + } + else -> { + stringResource(R.string.unlock_egk_top_bar_title) + } + }, + onCanChange = onCanChanged, + onNext = onNext, + nextText = stringResource(R.string.unlock_egk_next), + onCancel = { onCancel() } + ) +} + +private val PUKLengthRange = 8..8 + +@Composable +private fun PersonalUnblockingKeyScreen( + personalUnblockingKey: String, + unlockMethod: UnlockMethod, + onPersonalUnblockingKeyChanged: (String) -> Unit, + onCancel: () -> Unit, + onNext: (String) -> Unit +) { + PukScreen( + navMode = NavigationMode.Back, + secret = personalUnblockingKey, + unlockMethod = unlockMethod, + secretRange = PUKLengthRange, + onSecretChange = onPersonalUnblockingKeyChanged, + onCancel = onCancel, + next = onNext, + nextText = stringResource(R.string.unlock_egk_next) + ) +} + +@Composable +private fun PukScreen( + unlockMethod: UnlockMethod, + navMode: NavigationMode, + secret: String, + secretRange: IntRange, + onSecretChange: (String) -> Unit, + onCancel: () -> Unit, + next: (String) -> Unit, + nextText: String +) { + val listState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("cardWall/secretScreen"), + backMode = when (navMode) { + NavigationMode.Forward, + NavigationMode.Back, + NavigationMode.Closed -> NavigationBarMode.Back + NavigationMode.Open -> NavigationBarMode.Close + }, + title = if (unlockMethod == UnlockMethod.ResetRetryCounterWithNewSecret) { + stringResource(R.string.unlock_egk_top_bar_title_forgot_pin) + } else { + stringResource(R.string.unlock_egk_top_bar_title) + }, + listState = listState, + nextEnabled = secret.length in secretRange, + onNext = { next(secret) }, + nextText = nextText, + actions = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(PaddingDefaults.Medium), + state = listState + ) { + item { + Text( + stringResource(R.string.unlock_egk_enter_puk), + style = AppTheme.typography.h5 + ) + SpacerMedium() + } + item { + SecretInputField( + modifier = Modifier + .fillMaxWidth() + .scrollOnFocus(2, listState), + secretRange = secretRange, + onSecretChange = onSecretChange, + secret = secret, + label = stringResource(R.string.unlock_egk_puk_label), + next = next + ) + SpacerTiny() + Text( + stringResource(R.string.unlock_egk_puk_info), + style = AppTheme.typography.caption1l + ) + } + } + } +} + +@Composable +private fun OldSecretScreen( + oldSecret: String, + onSecretChange: (String) -> Unit, + onCancel: () -> Unit, + onNext: (String) -> Unit +) { + val secretRange = SECRET_MIN_LENGTH..SECRET_MAX_LENGTH + + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("cardWall/secretScreen"), + backMode = NavigationBarMode.Back, + title = stringResource(R.string.unlock_egk_top_bar_title_change_secret), + nextEnabled = oldSecret.length in secretRange, + listState = lazyListState, + onNext = { onNext(oldSecret) }, + nextText = stringResource(R.string.unlock_egk_next), + actions = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + item { + Text( + stringResource(R.string.unlock_egk_enter_old_secret), + style = AppTheme.typography.h5 + ) + SpacerMedium() + } + item { + SecretInputField( + modifier = Modifier + .fillMaxWidth() + .scrollOnFocus(2, lazyListState), + secretRange = secretRange, + onSecretChange = onSecretChange, + secret = oldSecret, + label = stringResource(R.string.unlock_egk_choose_new_secret_label), + next = onNext + ) + SpacerTiny() + Text( + stringResource(R.string.unlock_egk_pin_info), + style = AppTheme.typography.caption1l + ) + } + } + } +} + +@Composable +private fun NewSecretScreen( + newSecret: String, + onSecretChange: (String) -> Unit, + onCancel: () -> Unit, + onNext: (String) -> Unit +) { + val secretRange = SECRET_MIN_LENGTH..SECRET_MAX_LENGTH + var repeatedNewSecret by remember { mutableStateOf("") } + val isConsistent by derivedStateOf { + repeatedNewSecret.isNotBlank() && newSecret == repeatedNewSecret + } + + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("cardWall/secretScreen"), + backMode = NavigationBarMode.Back, + title = stringResource(R.string.unlock_egk_top_bar_title_change_secret), + nextEnabled = newSecret.length in secretRange && isConsistent, + listState = lazyListState, + onNext = { onNext(newSecret) }, + nextText = stringResource(R.string.unlock_egk_next), + actions = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + item { + Text( + stringResource(R.string.unlock_egk_new_secret_title), + style = AppTheme.typography.h5 + ) + SpacerMedium() + } + item { + SecretInputField( + modifier = Modifier + .fillMaxWidth() + .scrollOnFocus(2, lazyListState), + secretRange = secretRange, + onSecretChange = onSecretChange, + secret = newSecret, + label = stringResource(R.string.unlock_egk_choose_new_secret_label), + next = onNext + ) + SpacerTiny() + Text( + stringResource(R.string.unlock_egk_new_secret_info), + style = AppTheme.typography.caption1l + ) + } + + item { + SpacerLarge() + ConformationSecretInputField( + modifier = Modifier + .fillMaxWidth() + .scrollOnFocus(ConfInputfieldPosition, lazyListState), + secretRange = secretRange, + repeatedSecret = repeatedNewSecret, + onSecretChange = { repeatedNewSecret = it }, + secret = newSecret, + isConsistent = isConsistent, + label = stringResource(R.string.unlock_egk_repeat_secret_label), + next = onNext + ) + if (repeatedNewSecret.isNotBlank() && !isConsistent) { + SpacerTiny() + Text( + stringResource(R.string.not_matching_entries), + style = AppTheme.typography.caption1, + color = AppTheme.colors.red600.copy( + alpha = ContentAlpha.high + ) + ) + } + } + item { + SpacerXXLarge() + HintCard( + modifier = Modifier, + image = { + HintSmallImage( + painterResource(R.drawable.information), + innerPadding = it + ) + }, + title = { Text(stringResource(R.string.unlock_egk_new_secret_extra_content_title)) }, + body = { Text(stringResource(R.string.unlock_egk_new_secret_extra_content_info)) } + ) + SpacerMedium() + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun UnlockScreen( + unlockMethod: UnlockMethod, + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + oldSecret: String, + newSecret: String, + onClickTroubleshooting: () -> Unit, + onRetryCan: () -> Unit, + onRetryOldSecret: () -> Unit, + onRetryPuk: () -> Unit, + onBack: () -> Unit, + onFinishUnlock: () -> Unit, + onAssignPin: () -> Unit +) { + val nfcPositionViewModel by rememberViewModel() + val state by remember { mutableStateOf(nfcPositionViewModel.screenState()) } + val dialogState = rememberUnlockEgkDialogState() + + UnlockEgkDialog( + unlockMethod = unlockMethod, + dialogState = dialogState, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + troubleShootingEnabled = true, + onClickTroubleshooting = onClickTroubleshooting, + oldSecret = oldSecret, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryOldSecret = onRetryOldSecret, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock, + onAssignPin = onAssignPin + ) + + NFCInstructionScreen( + onBack = onBack, + onClickTroubleshooting = onClickTroubleshooting, + state = state + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt new file mode 100644 index 00000000..d794f064 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt @@ -0,0 +1,556 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardunlock.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.ui.platform.LocalContext +import de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.NfcNotEnabledException +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.cardunlock.usecase.UnlockEgkState +import de.gematik.ti.erp.app.cardwall.ui.CardAnimationBox +import de.gematik.ti.erp.app.cardwall.ui.EnableNfcDialog +import de.gematik.ti.erp.app.cardwall.ui.ErrorDialog +import de.gematik.ti.erp.app.cardwall.ui.Troubleshooting +import de.gematik.ti.erp.app.cardwall.ui.pinRetriesLeft +import de.gematik.ti.erp.app.cardwall.ui.rotatingScanCardAssistance +import de.gematik.ti.erp.app.cardwall.ui.toAnnotatedString +import de.gematik.ti.erp.app.core.LocalActivity +import de.gematik.ti.erp.app.settings.ui.buildFeedbackBodyWithDeviceInfo +import de.gematik.ti.erp.app.settings.ui.openMailClient +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import de.gematik.ti.erp.app.utils.compose.Dialog +import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.retryWhen +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +@Stable +class UnlockEgkDialogState { + val toggleUnlock = MutableSharedFlow() + + suspend fun show() { + toggleUnlock.emit(ToggleUnlock.ToggleByUser(true)) + } +} + +@Composable +fun rememberUnlockEgkDialogState(): UnlockEgkDialogState { + return remember { UnlockEgkDialogState() } +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongMethod", "LongParameterList") +@Composable +fun UnlockEgkDialog( + unlockMethod: UnlockMethod, + dialogState: UnlockEgkDialogState, + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + oldSecret: String, + newSecret: String, + onClickTroubleshooting: (() -> Unit)? = null, + troubleShootingEnabled: Boolean = false, + onRetryCan: () -> Unit, + onRetryOldSecret: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onAssignPin: () -> Unit +) { + val activity = LocalActivity.current as MainActivity + val coroutineScope = rememberCoroutineScope() + val toggleUnlock = dialogState.toggleUnlock + + var showEnableNfcDialog by remember { mutableStateOf(false) } + var errorCount by remember(troubleShootingEnabled) { mutableStateOf(0) } + var showCardCommunicationDialog by remember { mutableStateOf(false) } + + val state by produceState(initialValue = UnlockEgkState.None) { + toggleUnlock.transformLatest { + emit(UnlockEgkState.None) + when (it) { + is ToggleUnlock.ToggleByUser -> { + if (it.value) { + showCardCommunicationDialog = true + emitAll( + viewModel.unlockEgk( + unlockMethod = unlockMethod, + can = cardAccessNumber, + puk = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + tag = activity + .nfcTagFlow + .catch { + if (it is NfcNotEnabledException) { + showEnableNfcDialog = true + } + } + ) + ) + } else { + value = UnlockEgkState.None + } + } + + is ToggleUnlock.ToggleByHealthCard -> { + val collectedOnce = AtomicBoolean(false) + val tagFlow = flow { + if (collectedOnce.get()) { + activity.nfcTagFlow.collect { tag -> + emit(tag) + } + } else { + collectedOnce.set(true) + emit(it.tag) + } + } + emitAll( + viewModel.unlockEgk( + unlockMethod = unlockMethod, + can = cardAccessNumber, + puk = personalUnblockingKey, + oldSecret = oldSecret, + newSecret = newSecret, + tagFlow + ) + ) + } + } + }.catch { + Napier.e("Something unforeseen happened", it) + emit(UnlockEgkState.HealthCardCommunicationInterrupted) + delay(1000) + }.onCompletion { cause -> + if (cause is CancellationException) { + value = UnlockEgkState.None + } + }.collect { + errorCount += if (it == UnlockEgkState.HealthCardCommunicationInterrupted) 1 else 0 + value = it + } + } + + LaunchedEffect(Unit) { + activity.nfcTagFlow + .retryWhen { cause, _ -> + cause !is NfcNotEnabledException + } + .catch { cause -> + if (cause is NfcNotEnabledException) { + showEnableNfcDialog = true + } + } + .filter { + !(state.isFailure() && state != UnlockEgkState.HealthCardCommunicationInterrupted) + } + .collect { + toggleUnlock.emit(ToggleUnlock.ToggleByHealthCard(it)) + } + } + + LaunchedEffect(state) { + when { + state.isInProgress() -> showCardCommunicationDialog = true + state.isReady() -> showCardCommunicationDialog = false + } + } + + if (showCardCommunicationDialog) { + CardCommunicationDialog( + state, + onCancel = { + coroutineScope.launch { toggleUnlock.emit(ToggleUnlock.ToggleByUser(false)) } + }, + showTroubleshooting = troubleShootingEnabled && errorCount > 2 && !state.isInProgress(), + onClickTroubleshooting = onClickTroubleshooting + ) + } + + if (showEnableNfcDialog) { + EnableNfcDialog { + showEnableNfcDialog = false + } + } + + val nextText = nextTextFromUnlockEgkState(state) + + val resumeText = resumeTextFromUnlockEgkState(unlockMethod, state) + + resumeText?.let { + ResumeDialog( + state = state, + unlockMethod = unlockMethod, + resumeText = it, + onFinishUnlock = onFinishUnlock, + nextText = nextText, + onToggleUnlock = { + coroutineScope.launch { + toggleUnlock.emit(ToggleUnlock.ToggleByUser(it)) + } + }, + onRetryOldSecret = onRetryOldSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onAssignPin = onAssignPin + ) + } +} + +@Composable +private fun nextTextFromUnlockEgkState(state: UnlockEgkState): String { + val nextText = when (state) { + UnlockEgkState.HealthCardCardAccessNumberWrong, + UnlockEgkState.HealthCardPinRetriesLeft, + UnlockEgkState.HealthCardPukRetriesLeft -> stringResource(R.string.cdw_auth_retry_pin_can) + + UnlockEgkState.HealthCardCommunicationFinished, + UnlockEgkState.HealthCardPasswordBlocked, + UnlockEgkState.HealthCardPukBlocked -> stringResource(R.string.unlock_egk_finished_ok) + + UnlockEgkState.MemoryFailure, + UnlockEgkState.SecurityStatusNotSatisfied, + UnlockEgkState.PasswordNotFound -> stringResource(R.string.unlock_egk_report_error) + + UnlockEgkState.PasswordNotUsable -> stringResource(R.string.unlock_egk_assign_pin) + + else -> stringResource(R.string.cdw_auth_retry) + } + return nextText +} + +@Composable +private fun resumeTextFromUnlockEgkState( + unlockMethod: UnlockMethod, + state: UnlockEgkState +): Pair? { + val resumeText = when (state) { + UnlockEgkState.HealthCardCommunicationFinished -> Pair( + if (unlockMethod == UnlockMethod.ChangeReferenceData || + unlockMethod == UnlockMethod.ResetRetryCounterWithNewSecret + ) { + stringResource(R.string.unlock_egk_dialog_new_secret_saved).toAnnotatedString() + } else { + stringResource(R.string.unlock_egk_unlock_success_header).toAnnotatedString() + }, + if (unlockMethod == UnlockMethod.ChangeReferenceData) { + "".toAnnotatedString() + } else { + stringResource(R.string.unlock_egk_unlock_success_info).toAnnotatedString() + } + ) + + UnlockEgkState.HealthCardCardAccessNumberWrong -> Pair( + stringResource(R.string.cdw_nfc_intro_step2_header_on_can_error).toAnnotatedString(), + stringResource(R.string.cdw_nfc_intro_step2_info_on_can_error).toAnnotatedString() + ) + + UnlockEgkState.HealthCardPukRetriesLeft -> Pair( + stringResource(R.string.cdw_nfc_intro_step2_header_on_puk_error).toAnnotatedString(), + pukRetriesLeft(state.retriesLeft) + ) + + UnlockEgkState.HealthCardPinRetriesLeft -> Pair( + stringResource(R.string.unlock_egk_wrong_pin).toAnnotatedString(), + pinRetriesLeft(state.retriesLeft) + ) + UnlockEgkState.HealthCardPasswordBlocked -> Pair( + stringResource(R.string.unlock_egk_password_blocked).toAnnotatedString(), + stringResource(R.string.unlock_egk_password_blocked_info).toAnnotatedString() + ) + + UnlockEgkState.HealthCardPukBlocked -> Pair( + stringResource(R.string.unlock_not_possible_header).toAnnotatedString(), + stringResource(R.string.unlock_not_possible_info).toAnnotatedString() + ) + + UnlockEgkState.MemoryFailure -> Pair( + stringResource(R.string.unlock_memory_failure_header).toAnnotatedString(), + stringResource(R.string.unlock_memory_failure_info).toAnnotatedString() + ) + + UnlockEgkState.SecurityStatusNotSatisfied -> Pair( + stringResource(R.string.unlock_security_status_not_satisfied_header).toAnnotatedString(), + stringResource(R.string.unlock_security_status_not_satisfied_info).toAnnotatedString() + ) + + UnlockEgkState.PasswordNotUsable -> Pair( + stringResource(R.string.unlock_password_not_usable_header).toAnnotatedString(), + stringResource(R.string.unlock_password_not_usable_info).toAnnotatedString() + ) + + UnlockEgkState.PasswordNotFound -> Pair( + stringResource(R.string.unlock_password_not_found_header).toAnnotatedString(), + stringResource(R.string.unlock_password_not_found_info).toAnnotatedString() + ) + + else -> null + } + return resumeText +} + +@Composable +private fun ResumeDialog( + state: UnlockEgkState, + unlockMethod: UnlockMethod, + resumeText: Pair, + onFinishUnlock: () -> Unit, + nextText: String, + onToggleUnlock: (Boolean) -> Unit, + onRetryCan: () -> Unit, + onRetryOldSecret: () -> Unit, + onRetryPuk: () -> Unit, + onAssignPin: () -> Unit +) { + val context = LocalContext.current + val mailAddress = stringResource(R.string.settings_contact_mail_address) + val subject = stringResource(R.string.settings_feedback_mail_subject) + val body = buildFeedbackBodyWithDeviceInfo( + context = context, + errorState = remember(key1 = state.name) { state.name } + ) + + when (state) { + UnlockEgkState.HealthCardCommunicationFinished, + UnlockEgkState.HealthCardPasswordBlocked, + UnlockEgkState.HealthCardPukBlocked -> + AcceptDialog( + header = resumeText.first, + info = resumeText.second, + acceptText = stringResource(R.string.unlock_egk_finished_ok), + onClickAccept = { onFinishUnlock() } + ) + + else -> { + ErrorDialog( + header = resumeText.first, + info = resumeText.second, + retryButtonText = nextText, + onCancel = { + // don't retry + onToggleUnlock(false) + }, + onRetry = { + if (unlockMethod == UnlockMethod.ChangeReferenceData) { + when (state) { + UnlockEgkState.HealthCardCardAccessNumberWrong -> onRetryCan() + UnlockEgkState.HealthCardPinRetriesLeft -> onRetryOldSecret() + UnlockEgkState.PasswordNotFound, + UnlockEgkState.SecurityStatusNotSatisfied, + UnlockEgkState.MemoryFailure -> openMailClient(context, mailAddress, body, subject) + UnlockEgkState.PasswordNotUsable -> onAssignPin() + // retry + else -> onToggleUnlock(true) + } + } else { + when (state) { + UnlockEgkState.HealthCardCardAccessNumberWrong -> onRetryCan() + UnlockEgkState.HealthCardPukRetriesLeft -> onRetryPuk() + // retry + else -> onToggleUnlock(true) + } + } + } + ) + } + } +} + +@Composable +fun CardCommunicationDialog( + state: UnlockEgkState, + onCancel: () -> Unit, + showTroubleshooting: Boolean, + onClickTroubleshooting: (() -> Unit)? = null +) { + Dialog( + onDismissRequest = { onCancel() }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + Box( + Modifier + .fillMaxSize() + .background(SolidColor(Color.Black), alpha = 0.5f) + .systemBarsPadding(), + contentAlignment = Alignment.BottomCenter + ) { + Surface( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(28.dp), + elevation = 8.dp + ) { + val screen = remember(state) { + when (state) { + UnlockEgkState.None, + UnlockEgkState.UnlockFlowInitialized -> 0 + + UnlockEgkState.HealthCardCommunicationChannelReady, + UnlockEgkState.HealthCardCommunicationTrustedChannelEstablished, + UnlockEgkState.HealthCardCommunicationFinished -> 1 + + else -> 2 + } + } + + Column( + modifier = Modifier + .padding(24.dp) + .wrapContentSize() + .testTag("cdw_unlock_egk_bottom_sheet"), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.unlock_egk_dialog_cancel).uppercase(Locale.getDefault())) + } + + CardAnimationBox(screen) + + // how to hold your card + val rotatingScanCardAssistance = rotatingScanCardAssistance() + + var info by remember { mutableStateOf(rotatingScanCardAssistance.first()) } + + LaunchedEffect(state) { + if (state == UnlockEgkState.UnlockFlowInitialized) { + var i = 0 + while (true) { + info = rotatingScanCardAssistance[i] + + i = if (i < rotatingScanCardAssistance.size - 1) { + i + 1 + } else { + 0 + } + + delay(5000) + } + } + } + + info = when (state) { + UnlockEgkState.HealthCardCommunicationChannelReady -> Pair( + stringResource(R.string.cdw_nfc_found_headline), + stringResource(R.string.cdw_nfc_found_info) + ) + + UnlockEgkState.HealthCardCommunicationTrustedChannelEstablished -> Pair( + stringResource(R.string.cdw_nfc_communication_headline_certificate_loaded), + stringResource(R.string.cdw_nfc_communication_info) + ) + + UnlockEgkState.HealthCardCommunicationFinished -> Pair( + stringResource(R.string.cdw_nfc_communication_headline_challenge_signed), + stringResource(R.string.cdw_nfc_communication_info) + ) + + UnlockEgkState.HealthCardCommunicationInterrupted -> Pair( + stringResource(R.string.cdw_nfc_tag_lost_headline), + stringResource(R.string.cdw_nfc_tag_lost_info) + ) + + else -> info + } + if (showTroubleshooting) { + Troubleshooting( + onClick = { onClickTroubleshooting?.run { onClickTroubleshooting() } } + ) + } else { + Text( + info.first, + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + info.second, + style = AppTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } +} + +@Composable +fun pukRetriesLeft(count: Int) = + annotatedPluralsResource( + R.plurals.cdw_nfc_intro_step2_info_on_puk_error, + count, + buildAnnotatedString { withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(count.toString()) } } + ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt new file mode 100644 index 00000000..5bcaeb0a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardunlock.ui + +import android.nfc.Tag +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.cardunlock.usecase.UnlockEgkState +import de.gematik.ti.erp.app.cardunlock.usecase.UnlockEgkUseCase +import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcHealthCard +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +class UnlockEgkViewModel( + private val unlockEgkUseCase: UnlockEgkUseCase, + private val dispatchers: DispatchProvider +) : ViewModel() { + fun unlockEgk( + unlockMethod: UnlockMethod, + can: String, + puk: String, + oldSecret: String, + newSecret: String, + tag: Flow + ): Flow { + val cardChannel = tag.map { NfcHealthCard.connect(it) } + return unlockEgkUseCase.unlockEgk( + unlockMethod = unlockMethod, + can = can, + puk = puk, + oldSecret = oldSecret, + newSecret = newSecret, + cardChannel = cardChannel + ).flowOn(dispatchers.IO) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardunlock/usecase/UnlockEgkUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/usecase/UnlockEgkUseCase.kt new file mode 100644 index 00000000..91220c0a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/usecase/UnlockEgkUseCase.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardunlock.usecase + +import android.nfc.TagLostException +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.card.model.command.ResponseException +import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardChannel +import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardSecureChannel +import de.gematik.ti.erp.app.card.model.command.ResponseStatus +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.card.model.exchange.unlockEgk +import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.establishTrustedChannel +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first +import io.github.aakira.napier.Napier +import java.io.IOException + +@Stable +enum class UnlockEgkState { + None, + UnlockFlowInitialized, + HealthCardCommunicationInterrupted, + HealthCardCommunicationChannelReady, + HealthCardCardAccessNumberWrong, + HealthCardPukRetriesLeft, + HealthCardPinRetriesLeft, + HealthCardPukBlocked, + HealthCardPasswordBlocked, + HealthCardCommunicationTrustedChannelEstablished, + MemoryFailure, + SecurityStatusNotSatisfied, + PasswordNotFound, + PasswordNotUsable, + HealthCardCommunicationFinished; + + var retriesLeft: Int = -1 + + @Stable + fun isFailure() = + when (this) { + HealthCardPasswordBlocked, + HealthCardPukRetriesLeft, + HealthCardCardAccessNumberWrong, + HealthCardCommunicationInterrupted, + MemoryFailure, + SecurityStatusNotSatisfied, + PasswordNotFound, + PasswordNotUsable, + HealthCardPukBlocked -> true + else -> false + } + + @Stable + fun isInProgress() = + when (this) { + HealthCardCommunicationChannelReady, + HealthCardCommunicationTrustedChannelEstablished -> true + + else -> false + } + + @Stable + fun isReady() = this == None +} + +class UnlockEgkUseCase { + fun unlockEgk( + unlockMethod: UnlockMethod, + can: String, + puk: String, + oldSecret: String, + newSecret: String, + cardChannel: Flow + ): Flow = + channelFlow { + send(UnlockEgkState.UnlockFlowInitialized) + cardChannel.first().use { nfcChannel -> + send(UnlockEgkState.HealthCardCommunicationChannelReady) + try { + healthCardCommunication(unlockMethod, nfcChannel, can, puk, oldSecret, newSecret) + } catch (expected: Exception) { + val state = handleException(unlockMethod, expected) + send(state) + } + } + } + + private fun handleException(unlockMethod: UnlockMethod, e: Throwable): UnlockEgkState = + when (e) { + is ResponseException -> { + @Suppress("MagicNumber") + if (unlockMethod == UnlockMethod.ChangeReferenceData) { + when (e.responseStatus) { + ResponseStatus.AUTHENTICATION_FAILURE -> UnlockEgkState.HealthCardCardAccessNumberWrong + ResponseStatus.PASSWORD_BLOCKED -> UnlockEgkState.HealthCardPasswordBlocked + ResponseStatus.MEMORY_FAILURE -> UnlockEgkState.MemoryFailure + ResponseStatus.SECURITY_STATUS_NOT_SATISFIED -> UnlockEgkState.SecurityStatusNotSatisfied + ResponseStatus.PASSWORD_NOT_FOUND -> UnlockEgkState.PasswordNotFound + ResponseStatus.PASSWORD_NOT_USABLE -> UnlockEgkState.PasswordNotUsable + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> + retriesLeft(UnlockEgkState.HealthCardPinRetriesLeft, 3) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> + retriesLeft(UnlockEgkState.HealthCardPinRetriesLeft, 2) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> + retriesLeft(UnlockEgkState.HealthCardPinRetriesLeft, 1) + else -> UnlockEgkState.HealthCardCommunicationInterrupted + } + } else { + when (e.responseStatus) { + ResponseStatus.AUTHENTICATION_FAILURE -> UnlockEgkState.HealthCardCardAccessNumberWrong + ResponseStatus.PUK_BLOCKED -> UnlockEgkState.HealthCardPukBlocked + ResponseStatus.PASSWORD_BLOCKED -> UnlockEgkState.HealthCardPasswordBlocked + ResponseStatus.MEMORY_FAILURE -> UnlockEgkState.MemoryFailure + ResponseStatus.SECURITY_STATUS_NOT_SATISFIED -> UnlockEgkState.SecurityStatusNotSatisfied + ResponseStatus.PASSWORD_NOT_FOUND -> UnlockEgkState.PasswordNotFound + ResponseStatus.PASSWORD_NOT_USABLE -> UnlockEgkState.PasswordNotUsable + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_09 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 9) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_08 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 8) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_07 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 7) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_06 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 6) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_05 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 5) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_04 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 4) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 3) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 2) + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 1) + + else -> UnlockEgkState.HealthCardCommunicationInterrupted + } + } + } + + is TagLostException, is IOException -> { + Napier.e("IO Exception / NFC TAG was lost", e) + UnlockEgkState.HealthCardCommunicationInterrupted + } + + else -> UnlockEgkState.HealthCardCommunicationInterrupted + } +} + +private suspend fun ProducerScope.healthCardCommunication( + unlockMethod: UnlockMethod, + channel: NfcCardChannel, + can: String, + puk: String, + oldSecret: String, + newSecret: String +) { + val paceKey = channel.establishTrustedChannel(can) + + val secChannel = NfcCardSecureChannel( + channel.isExtendedLengthSupported, + channel.card, + paceKey + ) + + send(UnlockEgkState.HealthCardCommunicationTrustedChannelEstablished) + + val response = secChannel.unlockEgk( + unlockMethod = unlockMethod, + puk = puk, + oldSecret = oldSecret, + newSecret = newSecret + ) + + send( + @Suppress("MagicNumber") + if (unlockMethod == UnlockMethod.ChangeReferenceData) { + when (response) { + ResponseStatus.SUCCESS -> UnlockEgkState.HealthCardCommunicationFinished + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> + retriesLeft(UnlockEgkState.HealthCardPinRetriesLeft, 3) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> + retriesLeft(UnlockEgkState.HealthCardPinRetriesLeft, 2) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> + retriesLeft(UnlockEgkState.HealthCardPinRetriesLeft, 1) + ResponseStatus.MEMORY_FAILURE -> UnlockEgkState.MemoryFailure + ResponseStatus.SECURITY_STATUS_NOT_SATISFIED -> UnlockEgkState.SecurityStatusNotSatisfied + ResponseStatus.PASSWORD_NOT_FOUND -> UnlockEgkState.PasswordNotFound + ResponseStatus.PASSWORD_NOT_USABLE -> UnlockEgkState.PasswordNotUsable + + else -> UnlockEgkState.HealthCardPasswordBlocked + } + } else { + when (response) { + ResponseStatus.SUCCESS -> UnlockEgkState.HealthCardCommunicationFinished + ResponseStatus.MEMORY_FAILURE -> UnlockEgkState.MemoryFailure + ResponseStatus.SECURITY_STATUS_NOT_SATISFIED -> UnlockEgkState.SecurityStatusNotSatisfied + ResponseStatus.PASSWORD_NOT_FOUND -> UnlockEgkState.PasswordNotFound + ResponseStatus.PASSWORD_NOT_USABLE -> UnlockEgkState.PasswordNotUsable + + ResponseStatus.WRONG_SECRET_WARNING_COUNT_09 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 9) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_08 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 8) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_07 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 7) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_06 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 6) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_05 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 5) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_04 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 4) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 3) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 2) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> + retriesLeft(UnlockEgkState.HealthCardPukRetriesLeft, 1) + else -> UnlockEgkState.HealthCardPukBlocked + } + } + ) +} + +private fun retriesLeft(state: UnlockEgkState, n: Int) = + state.apply { + this.retriesLeft = n + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/CardWallModule.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/CardWallModule.kt new file mode 100644 index 00000000..28a6cc94 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/CardWallModule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall + +import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase +import de.gematik.ti.erp.app.cardwall.usecase.CardWallLoadNfcPositionUseCase +import de.gematik.ti.erp.app.cardwall.usecase.CardWallUseCase +import de.gematik.ti.erp.app.cardwall.usecase.MiniCardWallUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +val cardWallModule = DI.Module("cardWallModule") { + bindProvider { AuthenticationUseCase(instance()) } + bindProvider { CardWallLoadNfcPositionUseCase(instance()) } + bindProvider { CardWallUseCase(instance(), instance()) } + bindSingleton { MiniCardWallUseCase(instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/domain/biometric/Biometric.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/domain/biometric/Biometric.kt new file mode 100644 index 00000000..76dc3ef9 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/domain/biometric/Biometric.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.domain.biometric + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.biometric.BiometricManager + +fun isDeviceSupportsBiometric(biometricMode: Int) = when (biometricMode) { + BiometricManager.BIOMETRIC_SUCCESS, + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + true + else -> + false +} + +fun deviceStrongBiometricStatus(context: Context): Int { + val biometricManager = BiometricManager.from(context) + return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) +} + +fun hasDeviceStrongBox(context: Context) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + } else { + false + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt new file mode 100644 index 00000000..974018d9 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.mini.ui + +import android.nfc.Tag +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PersonOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState +import de.gematik.ti.erp.app.core.IntentHandler +import de.gematik.ti.erp.app.idp.api.models.AuthenticationId +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.ui.Avatar +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import org.kodein.di.compose.rememberViewModel +import java.net.URI + +class NoneEnrolledException : IllegalStateException() +class UserNotAuthenticatedException : IllegalStateException() + +@Stable +interface PromptAuthenticator { + enum class AuthResult { + Authenticated, + Cancelled, + NoneEnrolled, + UserNotAuthenticated + } + + enum class AuthScope { + Prescriptions, PairedDevices + } + + fun authenticate(profileId: ProfileIdentifier, scope: AuthScope): Flow + + suspend fun cancelAuthentication() +} + +interface AuthenticationBridge { + @Stable + sealed interface InitialAuthenticationData { + val profile: ProfilesUseCaseData.Profile + } + + data class HealthCard(val can: String, override val profile: ProfilesUseCaseData.Profile) : + InitialAuthenticationData + + data class SecureElement(override val profile: ProfilesUseCaseData.Profile) : InitialAuthenticationData + data class External( + val authenticatorId: String, + val authenticatorName: String, + override val profile: ProfilesUseCaseData.Profile + ) : InitialAuthenticationData + + data class None(override val profile: ProfilesUseCaseData.Profile) : InitialAuthenticationData + + suspend fun authenticateFor( + profileId: ProfileIdentifier + ): InitialAuthenticationData + + fun doSecureElementAuthentication( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope + ): Flow + + fun doHealthCardAuthentication( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope, + can: String, + pin: String, + tag: Tag + ): Flow + + suspend fun loadExternalAuthenticators(): List + + suspend fun doExternalAuthentication( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope, + authenticatorId: String, + authenticatorName: String + ): Result + + suspend fun doExternalAuthorization( + redirect: URI + ): Result + + suspend fun doRemoveAuthentication(profileId: ProfileIdentifier) +} + +@Stable +class Authenticator( + val authenticatorSecureElement: SecureHardwarePromptAuthenticator, + val authenticatorHealthCard: HealthCardPromptAuthenticator, + val authenticatorExternal: ExternalPromptAuthenticator, + private val bridge: AuthenticationBridge +) { + fun authenticateForPrescriptions(profileId: ProfileIdentifier): Flow = + flow { + emitAll( + when (bridge.authenticateFor(profileId)) { + is AuthenticationBridge.HealthCard -> + authenticatorHealthCard.authenticate(profileId, PromptAuthenticator.AuthScope.Prescriptions) + + is AuthenticationBridge.SecureElement -> + authenticatorSecureElement.authenticate(profileId, PromptAuthenticator.AuthScope.Prescriptions) + + is AuthenticationBridge.External -> + authenticatorExternal.authenticate(profileId, PromptAuthenticator.AuthScope.Prescriptions) + + is AuthenticationBridge.None -> flowOf(PromptAuthenticator.AuthResult.NoneEnrolled) + } + ) + } + + fun authenticateForPairedDevices(profileId: ProfileIdentifier): Flow = + flow { + emitAll( + when (bridge.authenticateFor(profileId)) { + is AuthenticationBridge.HealthCard -> + authenticatorHealthCard.authenticate(profileId, PromptAuthenticator.AuthScope.PairedDevices) + + is AuthenticationBridge.SecureElement -> + authenticatorSecureElement.authenticate(profileId, PromptAuthenticator.AuthScope.PairedDevices) + + is AuthenticationBridge.External -> + authenticatorExternal.authenticate(profileId, PromptAuthenticator.AuthScope.PairedDevices) + + is AuthenticationBridge.None -> flowOf(PromptAuthenticator.AuthResult.NoneEnrolled) + } + ) + } + + suspend fun cancelAllAuthentications() { + authenticatorSecureElement.cancelAuthentication() + authenticatorHealthCard.cancelAuthentication() + } +} + +@Composable +fun PromptScaffold( + title: String, + profile: ProfilesUseCaseData.Profile?, + onCancel: () -> Unit, + content: @Composable () -> Unit +) { + Surface( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(16.dp), + elevation = 8.dp + ) { + Column( + Modifier + .padding(vertical = PaddingDefaults.Medium) + ) { + Row( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + profile?.let { + Avatar( + avatarModifier = Modifier.size(36.dp), + emptyIcon = Icons.Rounded.PersonOutline, + iconModifier = Modifier.size(20.dp), + profile = profile, + ssoStatusColor = null + ) + SpacerMedium() + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = AppTheme.typography.h6, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + Text( + profile.insuranceInformation.insuranceIdentifier, + style = AppTheme.typography.body2l + ) + } + } + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cdw_nfc_dlg_cancel)) + } + } + SpacerLarge() + content() + } + } +} + +@Composable +fun rememberAuthenticator(intentHandler: IntentHandler): Authenticator { + val bridge by rememberViewModel() + val promptSE = rememberSecureHardwarePromptAuthenticator(bridge) + val promptHC = rememberHealthCardPromptAuthenticator(bridge) + val promptEX = rememberExternalPromptAuthenticator(bridge, intentHandler) + return remember { + Authenticator( + authenticatorSecureElement = promptSE, + authenticatorHealthCard = promptHC, + authenticatorExternal = promptEX, + bridge = bridge + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/ExternalAuthPrompt.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/ExternalAuthPrompt.kt new file mode 100644 index 00000000..1c88d8f8 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/ExternalAuthPrompt.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.mini.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.IntentHandler +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.Dialog +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import io.github.aakira.napier.Napier +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import java.net.URI + +@Stable +class ExternalPromptAuthenticator( + private val intentHandler: IntentHandler, + private val bridge: AuthenticationBridge +) : PromptAuthenticator { + private sealed interface Request { + object InsuranceSelected : Request + object Cancel : Request + } + + private val requestChannel = Channel(Channel.RENDEZVOUS) + + @Stable + internal sealed interface State { + object None : State + data class SelectInsurance(val authenticatorName: String) : State + } + + internal var state by mutableStateOf(State.None) + + var profile by mutableStateOf(null) + private set + + var isInProgress: Boolean = false + private set + + override fun authenticate( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope + ): Flow = channelFlow { + when (val authFor = bridge.authenticateFor(profileId)) { + is AuthenticationBridge.External -> { + state = State.SelectInsurance(authFor.authenticatorName) + profile = authFor.profile + + requestChannel.receiveAsFlow().collectLatest { + when (it) { + Request.Cancel -> { + send(PromptAuthenticator.AuthResult.Cancelled) + cancel() + } + + is Request.InsuranceSelected -> { + Napier.d("doExternalAuthentication for $authFor") + + bridge.doExternalAuthentication( + profileId = profileId, + scope = scope, + authenticatorId = authFor.authenticatorId, + authenticatorName = authFor.authenticatorName + ).onSuccess { redirect -> + intentHandler.startFastTrackApp(redirect) + }.onFailure { + Napier.e("doExternalAuthentication failed", it) + // TODO error handling + send(PromptAuthenticator.AuthResult.Cancelled) + cancel() + } + + Napier.d("wait for instant of $authFor") + + val uri = intentHandler.extAuthIntent.first() + + Napier.d("doExternalAuthorization for $uri") + + bridge.doExternalAuthorization(URI(uri)) + .onSuccess { + send(PromptAuthenticator.AuthResult.Authenticated) + cancel() + } + .onFailure { + Napier.e("doExternalAuthorization failed", it) + // TODO error handling + send(PromptAuthenticator.AuthResult.Cancelled) + cancel() + } + } + } + } + } + + else -> { + send(PromptAuthenticator.AuthResult.Cancelled) + } + } + }.onStart { + isInProgress = true + }.onCompletion { + isInProgress = false + state = State.None + profile = null + } + + internal suspend fun onInsuranceSelected() { + requestChannel.send(Request.InsuranceSelected) + } + + internal suspend fun onCancel() { + requestChannel.send(Request.Cancel) + } + + override suspend fun cancelAuthentication() { + requestChannel.send(Request.Cancel) + } +} + +@Composable +fun ExternalAuthPrompt( + authenticator: ExternalPromptAuthenticator +) { + val scope = rememberCoroutineScope() + val state = authenticator.state + val profile = authenticator.profile + + if (state is ExternalPromptAuthenticator.State.SelectInsurance) { + Dialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + Box( + Modifier + .semantics(false) { } + .fillMaxSize() + .background(SolidColor(Color.Black), alpha = 0.5f) + .imePadding() + .systemBarsPadding(), + contentAlignment = Alignment.BottomCenter + ) { + PromptScaffold( + title = stringResource(R.string.cdw_fasttrack_choose_insurance), + profile = profile, + onCancel = { + scope.launch { + authenticator.onCancel() + } + } + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) + ) { + OutlinedTextField( + value = state.authenticatorName, + onValueChange = {}, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + label = { Text("Suche") }, + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedLabelColor = AppTheme.colors.neutral400, + placeholderColor = AppTheme.colors.neutral400, + trailingIconColor = AppTheme.colors.neutral400 + ), + readOnly = true + ) + SpacerMedium() + PrimaryButtonSmall( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + scope.launch { + authenticator.onInsuranceSelected() + } + } + ) { + Text(stringResource(R.string.mini_cdw_fasttrack_next)) + } + } + } + } + } + } +} + +@Composable +fun rememberExternalPromptAuthenticator( + bridge: AuthenticationBridge, + intentHandler: IntentHandler +): ExternalPromptAuthenticator { + return remember { + ExternalPromptAuthenticator(intentHandler, bridge) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/HealthCardPrompt.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/HealthCardPrompt.kt new file mode 100644 index 00000000..a6803e3f --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/HealthCardPrompt.kt @@ -0,0 +1,616 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.mini.ui + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconToggleButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.NfcNotEnabledException +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.ui.ReadingCardAnimation +import de.gematik.ti.erp.app.cardwall.ui.SearchingCardAnimation +import de.gematik.ti.erp.app.cardwall.ui.TagLostCard +import de.gematik.ti.erp.app.cardwall.ui.pinRetriesLeft +import de.gematik.ti.erp.app.cardwall.ui.toAnnotatedString +import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.Dialog +import de.gematik.ti.erp.app.utils.compose.PrimaryButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +@Stable +class HealthCardPromptAuthenticator( + val activity: MainActivity, + private val bridge: AuthenticationBridge +) : PromptAuthenticator { + private sealed interface Request { + class CredentialsEntered(val pin: String) : Request + object Cancel : Request + } + + private val requestChannel = Channel(Channel.RENDEZVOUS) + + internal sealed interface State { + object None : State + object EnterCredentials : State + + sealed interface ReadState : State { + object Searching : ReadState + + sealed interface Reading : ReadState { + object Reading00 : Reading + object Reading25 : Reading + object Reading50 : Reading + object Reading75 : Reading + object Success : Reading + } + + sealed interface Error : ReadState { + object NfcDisabled : Error + object TagLost : Error + object RemoteCommunicationFailed : Error + object CardAccessNumberWrong : Error + class PersonalIdentificationWrong(val retriesLeft: Int) : Error + object HealthCardBlocked : Error + object RemoteCommunicationInvalidCertificate : Error + object RemoteCommunicationInvalidOCSP : Error + } + } + } + + internal var state by mutableStateOf(State.None) + private set + + var profile by mutableStateOf(null) + private set + + private val tagFlow = activity.nfcTagFlow + .filter { + // only let interrupted communications through + !(state is State.ReadState.Error && state!=State.ReadState.Error.TagLost) + } + + override fun authenticate( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope + ): Flow = channelFlow { + val requestChannelFlow = requestChannel.receiveAsFlow() + + when (val authFor = bridge.authenticateFor(profileId)) { + is AuthenticationBridge.HealthCard -> { + state = State.EnterCredentials + profile = authFor.profile + + requestChannelFlow.collectLatest { req -> + when (req) { + Request.Cancel -> { + send(PromptAuthenticator.AuthResult.Cancelled) + cancel() + } + + is Request.CredentialsEntered -> { + state = State.ReadState.Searching + + tagFlow + .catch { + if (it is NfcNotEnabledException) { + state = State.ReadState.Error.NfcDisabled + } + } + .collectLatest { tag -> + bridge.doHealthCardAuthentication( + profileId = profileId, + scope = scope, + can = authFor.can, + pin = req.pin, + tag = tag + ).collect { + it.emitAuthState() + if (it.isFinal()) { + send(PromptAuthenticator.AuthResult.Authenticated) + cancel() + } + } + } + } + } + } + } + + else -> { + send(PromptAuthenticator.AuthResult.Cancelled) + } + } + }.onCompletion { + state = State.None + profile = null + } + + private fun AuthenticationState.emitAuthState() { + when { + isInProgress() -> { + when (this) { + AuthenticationState.HealthCardCommunicationChannelReady -> + state = State.ReadState.Reading.Reading00 + + AuthenticationState.HealthCardCommunicationTrustedChannelEstablished -> + state = State.ReadState.Reading.Reading25 + + AuthenticationState.HealthCardCommunicationFinished -> + state = State.ReadState.Reading.Reading50 + + AuthenticationState.IDPCommunicationFinished -> + state = State.ReadState.Reading.Reading75 + + else -> {} + } + } + + isFailure() -> { + state = when (this) { + AuthenticationState.HealthCardCommunicationInterrupted -> + State.ReadState.Error.TagLost + + AuthenticationState.HealthCardCardAccessNumberWrong -> + State.ReadState.Error.CardAccessNumberWrong + + AuthenticationState.HealthCardPin2RetriesLeft -> + State.ReadState.Error.PersonalIdentificationWrong(2) + + AuthenticationState.HealthCardPin1RetryLeft -> + State.ReadState.Error.PersonalIdentificationWrong(1) + + AuthenticationState.HealthCardBlocked -> + State.ReadState.Error.HealthCardBlocked + + AuthenticationState.IDPCommunicationFailed -> + State.ReadState.Error.RemoteCommunicationFailed + + AuthenticationState.IDPCommunicationInvalidCertificate -> + State.ReadState.Error.RemoteCommunicationInvalidCertificate + + AuthenticationState.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate -> + State.ReadState.Error.RemoteCommunicationInvalidOCSP + + else -> + State.ReadState.Error.TagLost + } + } + } + } + + override suspend fun cancelAuthentication() { + requestChannel.trySend(Request.Cancel) + } + + internal suspend fun onCancel() { + requestChannel.send(Request.Cancel) + } + + internal suspend fun onCredentialsEntered(pin: String) { + requestChannel.send(Request.CredentialsEntered(pin)) + } +} + +@Composable +fun rememberHealthCardPromptAuthenticator( + bridge: AuthenticationBridge +): HealthCardPromptAuthenticator { + val activity = LocalContext.current as MainActivity + return remember { + HealthCardPromptAuthenticator(activity, bridge) + } +} + +@Composable +fun HealthCardPrompt( + authenticator: HealthCardPromptAuthenticator +) { + val scope = rememberCoroutineScope() + val state = authenticator.state + val profile = authenticator.profile + + val isError = state is HealthCardPromptAuthenticator.State.ReadState.Error + val isTagLost = state is HealthCardPromptAuthenticator.State.ReadState.Error.TagLost + + if (state != HealthCardPromptAuthenticator.State.None && (!isError || isTagLost)) { + Dialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + Box( + Modifier + .semantics(false) { } + .fillMaxSize() + .background(SolidColor(Color.Black), alpha = 0.5f) + .verticalScroll(rememberScrollState()) + .imePadding() + .systemBarsPadding(), + contentAlignment = Alignment.BottomCenter + ) { + PromptScaffold( + title = stringResource(R.string.mini_cdw_title), + profile = profile, + onCancel = { + scope.launch { + authenticator.onCancel() + } + } + ) { + when (state) { + HealthCardPromptAuthenticator.State.EnterCredentials -> + HealthCardCredentials( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + onNext = { + scope.launch { + authenticator.onCredentialsEntered(it) + } + } + ) + + is HealthCardPromptAuthenticator.State.ReadState -> + HealthCardAnimation( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + state = state + ) + + else -> {} + } + } + } + } + } + if (isError) { + HealthCardErrorDialog( + state = state as HealthCardPromptAuthenticator.State.ReadState.Error, + onCancel = { + scope.launch { + authenticator.onCancel() + } + }, + onEnableNfc = { + scope.launch(Dispatchers.Main) { + authenticator.activity.startActivity( + Intent(Settings.ACTION_NFC_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + authenticator.onCancel() + } + } + ) + } +} + +private val PinRegex = """^\d{0,8}$""".toRegex() +private val PinCorrectRegex = """^\d{6,8}$""".toRegex() + +@Composable +private fun HealthCardCredentials( + modifier: Modifier, + onNext: (pin: String) -> Unit +) { + var pin by remember { mutableStateOf("") } + var pinVisible by remember { mutableStateOf(false) } + val pinCorrect by derivedStateOf { pin.matches(PinCorrectRegex) } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Large) + ) { + Text( + stringResource(R.string.mini_cdw_intro_description), + style = AppTheme.typography.body2l + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = pin, + onValueChange = { + if (it.matches(PinRegex)) { + pin = it + } + }, + label = { Text(stringResource(R.string.mini_cdw_pin_input_label)) }, + placeholder = { Text(stringResource(R.string.mini_cdw_pin_input_placeholder)) }, + visualTransformation = if (pinVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.NumberPassword + ), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedLabelColor = AppTheme.colors.neutral400, + placeholderColor = AppTheme.colors.neutral400, + trailingIconColor = AppTheme.colors.neutral400 + ), + keyboardActions = KeyboardActions { + onNext(pin) + }, + trailingIcon = { + IconToggleButton( + checked = pinVisible, + onCheckedChange = { pinVisible = it } + ) { + Icon( + if (pinVisible) { + Icons.Rounded.Visibility + } else { + Icons.Rounded.VisibilityOff + }, + null + ) + } + } + ) + PrimaryButton( + onClick = { onNext(pin) }, + enabled = pinCorrect, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.mini_cdw_pin_next)) + } + } +} + +private const val InfoTextRoundTime = 5000L + +@Composable +private fun HealthCardAnimation( + modifier: Modifier, + state: HealthCardPromptAuthenticator.State.ReadState +) { + Column( + modifier = modifier + .padding(PaddingDefaults.Large) + .wrapContentSize() + .testTag("cdw_auth_nfc_bottom_sheet"), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .defaultMinSize(minHeight = 150.dp) + .fillMaxWidth() + ) { + when (state) { + HealthCardPromptAuthenticator.State.ReadState.Searching -> SearchingCardAnimation() + is HealthCardPromptAuthenticator.State.ReadState.Reading -> ReadingCardAnimation() + is HealthCardPromptAuthenticator.State.ReadState.Error -> TagLostCard() + } + } + + // how to hold your card + val rotatingScanCardAssistance = listOf( + Pair( + stringResource(R.string.cdw_nfc_search1_headline), + stringResource(R.string.cdw_nfc_search1_info) + ), + Pair( + stringResource(R.string.cdw_nfc_search2_headline), + stringResource(R.string.cdw_nfc_search2_info) + ), + Pair( + stringResource(R.string.cdw_nfc_search3_headline), + stringResource(R.string.cdw_nfc_search3_info) + ) + ) + + var info by remember { mutableStateOf(rotatingScanCardAssistance.first()) } + + LaunchedEffect(Unit) { + while (true) { + snapshotFlow { state } + .first { + state is HealthCardPromptAuthenticator.State.ReadState.Searching + } + + var i = 0 + while (state is HealthCardPromptAuthenticator.State.ReadState.Searching) { + info = rotatingScanCardAssistance[i] + + i = if (i < rotatingScanCardAssistance.size - 1) { + i + 1 + } else { + 0 + } + + delay(InfoTextRoundTime) + } + } + } + + info = when (state) { + HealthCardPromptAuthenticator.State.ReadState.Reading.Reading00 -> Pair( + stringResource(R.string.cdw_nfc_found_headline), + stringResource(R.string.cdw_nfc_found_info) + ) + + HealthCardPromptAuthenticator.State.ReadState.Reading.Reading25 -> Pair( + stringResource(R.string.cdw_nfc_communication_headline_trusted_channel_established), + stringResource(R.string.cdw_nfc_communication_info) + ) + + HealthCardPromptAuthenticator.State.ReadState.Reading.Reading50 -> Pair( + stringResource(R.string.cdw_nfc_communication_headline_certificate_loaded), + stringResource(R.string.cdw_nfc_communication_info) + ) + + HealthCardPromptAuthenticator.State.ReadState.Reading.Reading75 -> Pair( + stringResource(R.string.cdw_nfc_communication_headline_pin_verified), + stringResource(R.string.cdw_nfc_communication_info) + ) + + HealthCardPromptAuthenticator.State.ReadState.Reading.Success -> Pair( + stringResource(R.string.cdw_nfc_communication_headline_challenge_signed), + stringResource(R.string.cdw_nfc_communication_info) + ) + + HealthCardPromptAuthenticator.State.ReadState.Error.TagLost -> Pair( + stringResource(R.string.cdw_nfc_tag_lost_headline), + stringResource(R.string.cdw_nfc_tag_lost_info) + ) + + else -> info + } + + Text( + info.first, + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + info.second, + style = AppTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun HealthCardErrorDialog( + state: HealthCardPromptAuthenticator.State.ReadState.Error, + onCancel: () -> Unit, + onEnableNfc: () -> Unit +) { + if (state == HealthCardPromptAuthenticator.State.ReadState.Error.NfcDisabled) { + CommonAlertDialog( + header = stringResource(R.string.cdw_enable_nfc_header), + info = stringResource(R.string.cdw_enable_nfc_info), + cancelText = stringResource(R.string.cancel), + actionText = stringResource(R.string.cdw_enable_nfc_btn_text), + onCancel = onCancel, + onClickAction = onEnableNfc + ) + } else { + val retryText = when (state) { + HealthCardPromptAuthenticator.State.ReadState.Error.RemoteCommunicationFailed -> Pair( + stringResource(R.string.cdw_nfc_intro_step1_header_on_error).toAnnotatedString(), + stringResource(R.string.cdw_idp_error_time_and_connection).toAnnotatedString() + ) + + HealthCardPromptAuthenticator.State.ReadState.Error.RemoteCommunicationInvalidCertificate -> Pair( + stringResource(R.string.cdw_nfc_error_title_invalid_certificate).toAnnotatedString(), + stringResource(R.string.cdw_nfc_error_body_invalid_certificate).toAnnotatedString() + ) + + HealthCardPromptAuthenticator.State.ReadState.Error.RemoteCommunicationInvalidOCSP -> Pair( + stringResource(R.string.cdw_nfc_error_title_invalid_ocsp_response_of_health_card_certificate) + .toAnnotatedString(), + stringResource(R.string.cdw_nfc_error_body_invalid_ocsp_response_of_health_card_certificate) + .toAnnotatedString() + ) + + HealthCardPromptAuthenticator.State.ReadState.Error.CardAccessNumberWrong -> Pair( + stringResource(R.string.cdw_nfc_intro_step2_header_on_can_error).toAnnotatedString(), + stringResource(R.string.cdw_nfc_intro_step2_info_on_can_error).toAnnotatedString() + ) + + is HealthCardPromptAuthenticator.State.ReadState.Error.PersonalIdentificationWrong -> Pair( + stringResource(R.string.cdw_nfc_intro_step2_header_on_pin_error).toAnnotatedString(), + pinRetriesLeft(state.retriesLeft) + ) + + HealthCardPromptAuthenticator.State.ReadState.Error.HealthCardBlocked -> Pair( + stringResource(R.string.cdw_header_on_card_blocked).toAnnotatedString(), + stringResource(R.string.cdw_info_on_card_blocked).toAnnotatedString() + ) + + else -> null + } + + retryText?.let { (title, message) -> + + AcceptDialog( + header = title, + info = message, + acceptText = stringResource(R.string.ok), + onClickAccept = onCancel + ) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt new file mode 100644 index 00000000..b385ef50 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.mini.ui + +import android.nfc.Tag +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcHealthCard +import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState +import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase +import de.gematik.ti.erp.app.cardwall.usecase.MiniCardWallUseCase +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.idp.api.models.AuthenticationId +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.idp.repository.IdpRepository +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import java.net.URI + +/** + * The [MiniCardWallViewModel] is used for refreshing tokens of several authentication methods. + * While the actual mini card wall is just the prompt for authentication with health card or external authentication, + * the biometric/alternate authentication uses the prompt provided by the system. + */ + +class MiniCardWallViewModel( + private val useCase: MiniCardWallUseCase, + private val authenticationUseCase: AuthenticationUseCase, + private val idpUseCase: IdpUseCase, + private val idpRepository: IdpRepository, + private val dispatchers: DispatchProvider +) : ViewModel(), AuthenticationBridge { + private fun PromptAuthenticator.AuthScope.toIdpScope() = + when (this) { + PromptAuthenticator.AuthScope.Prescriptions -> IdpScope.Default + PromptAuthenticator.AuthScope.PairedDevices -> IdpScope.BiometricPairing + } + + override suspend fun authenticateFor( + profileId: ProfileIdentifier + ): AuthenticationBridge.InitialAuthenticationData { + val profile = useCase.profileData(profileId).first() + return when (val ssoTokenScope = useCase.authenticationData(profileId).first().singleSignOnTokenScope) { + is IdpData.ExternalAuthenticationToken -> AuthenticationBridge.External( + authenticatorId = ssoTokenScope.authenticatorId, + authenticatorName = ssoTokenScope.authenticatorName, + profile = profile + ) + + is IdpData.AlternateAuthenticationToken, + is IdpData.AlternateAuthenticationWithoutToken -> AuthenticationBridge.SecureElement(profile = profile) + + is IdpData.DefaultToken -> AuthenticationBridge.HealthCard( + can = ssoTokenScope.cardAccessNumber, + profile = profile + ) + + null -> AuthenticationBridge.None(profile = profile) + } + } + + override fun doSecureElementAuthentication( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope + ): Flow { + return authenticationUseCase.authenticateWithSecureElement( + profileId = profileId, + scope = scope.toIdpScope() + ).flowOn(dispatchers.IO) + } + + override fun doHealthCardAuthentication( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope, + can: String, + pin: String, + tag: Tag + ): Flow { + return authenticationUseCase.authenticateWithHealthCard( + profileId = profileId, + scope = scope.toIdpScope(), + can = can, + pin = pin, + cardChannel = flow { emit(NfcHealthCard.connect(tag)) } + ).flowOn(dispatchers.IO) + } + + override suspend fun loadExternalAuthenticators(): List = + withContext(dispatchers.IO) { + idpUseCase.loadExternAuthenticatorIDs() + } + + override suspend fun doExternalAuthentication( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope, + authenticatorId: String, + authenticatorName: String + ): Result = withContext(dispatchers.IO) { + runCatching { + idpUseCase.getUniversalLinkForExternalAuthorization( + profileId = profileId, + scope = scope.toIdpScope(), + authenticatorId = authenticatorId, + authenticatorName = authenticatorName + ) + } + } + + override suspend fun doExternalAuthorization(redirect: URI): Result = withContext(dispatchers.IO) { + runCatching { + idpUseCase.authenticateWithExternalAppAuthorization(redirect) + } + } + + override suspend fun doRemoveAuthentication(profileId: ProfileIdentifier) { + withContext(dispatchers.IO) { + idpRepository.invalidate(profileId) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/SecureHardwarePrompt.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/SecureHardwarePrompt.kt new file mode 100644 index 00000000..567e7f2c --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/SecureHardwarePrompt.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.mini.ui + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.ui.toAnnotatedString +import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import io.github.aakira.napier.Napier + +@Stable +class SecureHardwarePromptAuthenticator( + val activity: FragmentActivity, + private val bridge: AuthenticationBridge, + private val promptInfo: BiometricPrompt.PromptInfo +) : PromptAuthenticator { + private val executor = ContextCompat.getMainExecutor(activity) + private val cancelRequest = Channel(Channel.RENDEZVOUS) + + @Stable + sealed interface Error { + object RemoteCommunicationFailed : Error + class RemoteCommunicationAltAuthNotSuccessful(val profileId: ProfileIdentifier) : Error + object RemoteCommunicationInvalidCertificate : Error + object RemoteCommunicationInvalidOCSP : Error + } + + var showError: Error? by mutableStateOf(null) + private set + + fun resetErrorState() { + showError = null + } + + suspend fun removeAuthentication(profileId: ProfileIdentifier) { + bridge.doRemoveAuthentication(profileId) + } + + override fun authenticate( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope + ): Flow = callbackFlow { + launch { + cancelRequest.receive() + send(PromptAuthenticator.AuthResult.Cancelled) + cancel() + } + + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + + trySendBlocking(PromptAuthenticator.AuthResult.Authenticated) + + channel.close() + } + + override fun onAuthenticationError( + errCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError(errCode, errString) + + Napier.e("Failed to authenticate: $errString") + + trySendBlocking(PromptAuthenticator.AuthResult.Cancelled) + + channel.close() + } + } + ) + + prompt.authenticate(promptInfo) + + awaitClose { + prompt.cancelAuthentication() + } + }.flowOn(Dispatchers.Main) + .map { + if (it == PromptAuthenticator.AuthResult.Authenticated) { + bridge.doSecureElementAuthentication( + profileId = profileId, + scope = scope + ).first { authState -> + authState.isFailure() || authState.isFinal() + }.let { authState -> + when { + authState.isNotAuthenticatedFailure() -> + PromptAuthenticator.AuthResult.UserNotAuthenticated + + authState.isFailure() -> { + showError = when (authState) { + AuthenticationState.IDPCommunicationAltAuthNotSuccessful -> + Error.RemoteCommunicationAltAuthNotSuccessful(profileId) + AuthenticationState.IDPCommunicationFailed -> + Error.RemoteCommunicationFailed + AuthenticationState.IDPCommunicationInvalidCertificate -> + Error.RemoteCommunicationInvalidCertificate + AuthenticationState.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate -> + Error.RemoteCommunicationInvalidOCSP + else -> null + } + PromptAuthenticator.AuthResult.Cancelled + } + + authState.isFinal() -> + PromptAuthenticator.AuthResult.Authenticated + + else -> + error("unreachable") + } + } + } else { + it + } + } + + override suspend fun cancelAuthentication() { + cancelRequest.trySend(Unit) + } +} + +@Composable +fun rememberSecureHardwarePromptAuthenticator( + bridge: AuthenticationBridge +): SecureHardwarePromptAuthenticator { + val activity = LocalContext.current as FragmentActivity + val title = stringResource(R.string.alternate_auth_header) + val description = stringResource(R.string.alternate_auth_info) + val negativeButton = stringResource(R.string.cancel) + val promptInfo = remember { + BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setDescription(description) + .setNegativeButtonText(negativeButton) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG + ) + .build() + } + return remember { + SecureHardwarePromptAuthenticator(activity, bridge, promptInfo) + } +} + +@Composable +fun SecureHardwarePrompt( + authenticator: SecureHardwarePromptAuthenticator +) { + val scope = rememberCoroutineScope() + authenticator.showError?.let { error -> + val retryText = when (error) { + is SecureHardwarePromptAuthenticator.Error.RemoteCommunicationAltAuthNotSuccessful -> Pair( + stringResource(R.string.cdw_mini_alt_auth_removed_title).toAnnotatedString(), + stringResource(R.string.cdw_mini_alt_auth_removed).toAnnotatedString() + ) + + SecureHardwarePromptAuthenticator.Error.RemoteCommunicationFailed -> Pair( + stringResource(R.string.cdw_nfc_intro_step1_header_on_error).toAnnotatedString(), + stringResource(R.string.cdw_idp_error_time_and_connection).toAnnotatedString() + ) + + SecureHardwarePromptAuthenticator.Error.RemoteCommunicationInvalidCertificate -> Pair( + stringResource(R.string.cdw_nfc_error_title_invalid_certificate).toAnnotatedString(), + stringResource(R.string.cdw_nfc_error_body_invalid_certificate).toAnnotatedString() + ) + + SecureHardwarePromptAuthenticator.Error.RemoteCommunicationInvalidOCSP -> Pair( + stringResource(R.string.cdw_nfc_error_title_invalid_ocsp_response_of_health_card_certificate) + .toAnnotatedString(), + stringResource(R.string.cdw_nfc_error_body_invalid_ocsp_response_of_health_card_certificate) + .toAnnotatedString() + ) + } + + retryText.let { (title, message) -> + AcceptDialog( + header = title, + info = message, + acceptText = stringResource(R.string.ok), + onClickAccept = { + scope.launch { + if (error is SecureHardwarePromptAuthenticator.Error.RemoteCommunicationAltAuthNotSuccessful) { + authenticator.removeAuthentication(error.profileId) + } + authenticator.resetErrorState() + } + } + ) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/CardKey.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/CardKey.kt deleted file mode 100644 index 039d4ee3..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/CardKey.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.card - -private const val MIN_KEY_ID = 2 -private const val MAX_KEY_ID = 28 - -/** - * Class applies for symmetric keys and private keys. - */ -class CardKey(private val keyId: Int) : ICardKeyReference { - init { - require(!(keyId < MIN_KEY_ID || keyId > MAX_KEY_ID)) { - // gemSpec_COS#N016.400 and #N017.100 - String.format( - "Key ID out of range [%d,%d]", - MIN_KEY_ID, - MAX_KEY_ID - ) - } - } - - override fun calculateKeyReference(dfSpecific: Boolean): Int { - // gemSpec_COS#N099.600 - var keyReference = keyId - if (dfSpecific) { - keyReference += ICardKeyReference.DF_SPECIFIC_PWD_MARKER - } - return keyReference - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/HealthCardVersion2.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/HealthCardVersion2.kt deleted file mode 100644 index 914f66de..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/HealthCardVersion2.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.card - -import org.bouncycastle.asn1.ASN1InputStream -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DLSequence -import org.bouncycastle.asn1.DLTaggedObject -import java.io.IOException - -/** - * Represent the Version2 information of HealthCard - */ -class HealthCardVersion2( - /** - * Information of C0 with version of filling instruction for version2 - */ - val fillingInstructionsVersion: ByteArray, // C0 - /** - * Information of C1 with version of card object system - */ - val objectSystemVersion: ByteArray, // C1 - /** - * Information of C2 with version of product identification object system - */ - val productIdentificationObjectSystemVersion: ByteArray, // C2 - /** - * Information of C4 with version of filling instruction for EF.GDO - */ - val fillingInstructionsEfGdoVersion: ByteArray, // C4 - /** - * Information of C5 with version of filling instruction for EF.ATR - */ - val fillingInstructionsEfAtrVersion: ByteArray, // C5 - /** - * Information of C6 with version of filling instruction for EF.KeyInfo - * Only filled for gSMC-K and gSMC-KT - */ - val fillingInstructionsEfKeyInfoVersion: ByteArray, // C6 //only gSMC-K and gSMC-KT - /** - * Information of C3 with version of filling instruction for Environment Settings - * Only filled for gSMC-K - */ - val fillingInstructionsEfEnvironmentSettingsVersion: ByteArray, // C3 //only gSMC-K - /** - * Information of C7 with version of filling instruction for EF.GDO - */ - val fillingInstructionsEfLoggingVersion: ByteArray // C7 -) { - companion object { - private fun processData(data: ByteArray): Map = - ASN1InputStream(data).use { decoder -> - val tagMap = mutableMapOf() - (decoder.readObject() as DLTaggedObject) - .let { - (it.baseObject as DLSequence).objects.iterator().forEach { obj -> - tagMap[(obj as DLTaggedObject).tagNo] = (obj.baseObject as DEROctetString).octets - } - } - tagMap - } - - /** - * Create and fill a new instance of Version2 Object with available data from card response data - * - * @param data - * response data from card - * - * @return new instance of Version2 - * - * @throws IOException - */ - fun of(data: ByteArray) = - processData(data).let { - HealthCardVersion2( - fillingInstructionsVersion = it[0] ?: byteArrayOf(), - objectSystemVersion = it[1] ?: byteArrayOf(), - productIdentificationObjectSystemVersion = it[2] ?: byteArrayOf(), - fillingInstructionsEfEnvironmentSettingsVersion = it[3] ?: byteArrayOf(), - fillingInstructionsEfGdoVersion = it[4] ?: byteArrayOf(), - fillingInstructionsEfAtrVersion = it[5] ?: byteArrayOf(), - fillingInstructionsEfKeyInfoVersion = it[6] ?: byteArrayOf(), - fillingInstructionsEfLoggingVersion = it[7] ?: byteArrayOf() - ) - } - } -} - -const val EGK21_MIN_VERSION = (4 shl 16) or (4 shl 8) or 0 - -fun HealthCardVersion2.isEGK21(): Boolean { - val v = this.objectSystemVersion - val version = (v[0].toInt() shl 16) or (v[1].toInt() shl 8) or v[1].toInt() - - return version >= EGK21_MIN_VERSION -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardChannel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardChannel.kt deleted file mode 100644 index 046d0c2f..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardChannel.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.card - -import de.gematik.ti.erp.app.cardwall.model.nfc.command.CommandApdu -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseApdu - -/** - * Interface to a (logical) channel of a smart card. - * A channel object is used to send commands to and to receive answers from a smartcard. - * This is done by sending so called A-PDUs [CommandApdu] to smartcard. A smartcard returns - * a [ResponseApdu] - */ -interface ICardChannel { - /** - * Returns the Card this channel is associated with. - */ - val card: NfcHealthCard - - /** - * Max transceive length - */ - val maxTransceiveLength: Int - - /** - * Transmits the specified [CommandApdu] to the associated smartcard and returns the - * [ResponseApdu]. - * - * The CLA byte of the [CommandApdu] is automatically adjusted to match the channel number of this card channel - * since the channel number is coded into CLA byte of a command APDU according to ISO 7816-4. - * - * Implementations should transparently handle artifacts of the transmission protocol. - * - * The ResponseAPDU returned by this method is the result after this processing has been performed. - */ - fun transmit(command: CommandApdu): ResponseApdu - - /** - * Identify whether a channel supports APDU extended length commands and - * appropriate responses - */ - val isExtendedLengthSupported: Boolean -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardChannel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardChannel.kt index 49c67d69..84cf4a66 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardChannel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardChannel.kt @@ -18,13 +18,14 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.card -import de.gematik.ti.erp.app.cardwall.model.nfc.command.CommandApdu -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseApdu +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import java.io.Closeable class NfcCardChannel internal constructor( override val isExtendedLengthSupported: Boolean, - private val nfcHealthCard: NfcHealthCard, + private val nfcHealthCard: NfcHealthCard ) : ICardChannel, Closeable { override val card: NfcHealthCard get() = nfcHealthCard @@ -35,7 +36,7 @@ class NfcCardChannel internal constructor( * Returns the responseApdu after transmitting a commandApdu */ override fun transmit(command: CommandApdu): ResponseApdu = - nfcHealthCard.transceive(command) + nfcHealthCard.transmit(command) override fun close() { card.isoDep.close() diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardSecureChannel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardSecureChannel.kt index 23315ca2..06df9a01 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardSecureChannel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcCardSecureChannel.kt @@ -18,9 +18,12 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.card -import de.gematik.ti.erp.app.cardwall.model.nfc.command.CommandApdu -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseApdu -import timber.log.Timber +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.card.PaceKey +import de.gematik.ti.erp.app.card.model.card.SecureMessaging +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu +import io.github.aakira.napier.Napier class NfcCardSecureChannel internal constructor( override val isExtendedLengthSupported: Boolean, @@ -38,11 +41,11 @@ class NfcCardSecureChannel internal constructor( * Returns the responseApdu after transmitting a commandApdu */ override fun transmit(command: CommandApdu): ResponseApdu { - Timber.d("Encrypt ----") + Napier.d("Encrypt ----") return secureMessaging.encrypt(command).let { encryptedCommand -> - Timber.d("encrypted ----") - nfcHealthCard.transceive(encryptedCommand).let { encryptedResponse -> - Timber.d("Decrypt ----") + Napier.d("encrypted ----") + nfcHealthCard.transmit(encryptedCommand).let { encryptedResponse -> + Napier.d("Decrypt ----") secureMessaging.decrypt(encryptedResponse) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcHealthCard.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcHealthCard.kt index 249d5683..94d56bc0 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcHealthCard.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/NfcHealthCard.kt @@ -20,48 +20,33 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.card import android.nfc.Tag import android.nfc.tech.IsoDep -import de.gematik.ti.erp.app.cardwall.model.nfc.command.CommandApdu -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseApdu -import timber.log.Timber +import de.gematik.ti.erp.app.card.model.card.IHealthCard +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu +import io.github.aakira.napier.Napier private const val ISO_DEP_TIMEOUT = 2500 -class NfcHealthCard private constructor(val isoDep: IsoDep) { +class NfcHealthCard private constructor(val isoDep: IsoDep) : IHealthCard { - fun transceive(apduCommand: CommandApdu): ResponseApdu { - Timber.d("transceive ----") + override fun transmit(apduCommand: CommandApdu): ResponseApdu { + Napier.d("transceive ----") val resp = ResponseApdu(isoDep.transceive(apduCommand.bytes)) - Timber.d("transceived ----") + Napier.d("transceived ----") return resp } - /** - * Returns if card is present - * - * @return true if IsoDep not null and IsoDep is connected false if IsoDep is null or IsoDep is not connected - * @throws CardException - */ - private val isCardPresent: Boolean - get() { - var result: Boolean - isoDep.let { - result = isoDep.isConnected - Timber.d("isCardPresent() = %s", result) - } - return result - } - companion object { fun connect(tag: Tag): NfcCardChannel { val isoDep = IsoDep.get(tag).apply { - Timber.d("Try isoDep connect ...") + Napier.d("Try isoDep connect ...") connect() - Timber.d("... isoDep connected") - Timber.d("isoDep maxTransceiveLength: %s", maxTransceiveLength) - Timber.d("isoDep timeout: %s", timeout) + Napier.d("... isoDep connected") + Napier.d("isoDep maxTransceiveLength: $maxTransceiveLength") + Napier.d("isoDep timeout: $timeout") timeout = ISO_DEP_TIMEOUT - Timber.d("isoDep timeout set to: %s", timeout) + Napier.d("isoDep timeout set to: $timeout") } val healthCard = NfcHealthCard(isoDep) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/cardobjects/FileSystem.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/cardobjects/FileSystem.kt deleted file mode 100644 index b3b7d390..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/cardobjects/FileSystem.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects - -/** - * eGK 2.1 file system objects - * @see gemSpec_eGK_ObjSys_G2_1_V4_0_0 'Spezifikation der eGK Objektsystem G2.1' - */ - -object Ef { - object CardAccess { - const val FID = 0x011C - const val SFID = 0x1C - } - - object Version2 { - const val FID = 0x2F11 - const val SFID = 0x11 - } -} - -object Df { - object Esign { - const val AID = "A000000167455349474E" - } -} - -object Mf { - object MrPinHome { - const val PWID = 0x02 - } - object Df { - object Esign { - object Ef { - object CchAutE256 { - const val FID = 0xC504 - const val SFID = 0x04 - } - } - object PrK { - object ChAutE256 { - const val KID = 0x04 - } - } - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/Apdu.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/Apdu.kt deleted file mode 100644 index 1452739e..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/Apdu.kt +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import java.io.ByteArrayOutputStream - -/** - * Value for when wildcardShort for expected length encoding is needed - */ -const val EXPECTED_LENGTH_WILDCARD_EXTENDED: Int = 65536 -const val EXPECTED_LENGTH_WILDCARD_SHORT: Int = 256 - -private fun encodeDataLengthExtended(nc: Int): ByteArray = - byteArrayOf(0x0, (nc shr 8).toByte(), (nc and 0xFF).toByte()) - -private fun encodeDataLengthShort(nc: Int): ByteArray = - byteArrayOf(nc.toByte()) - -private fun encodeExpectedLengthExtended(ne: Int): ByteArray = - if (ne != EXPECTED_LENGTH_WILDCARD_EXTENDED) { // == 65536 - byteArrayOf((ne shr 8).toByte(), (ne and 0xFF).toByte()) // l1, l2 - } else { - byteArrayOf(0x0, 0x0) - } - -private fun encodeExpectedLengthShort(ne: Int): ByteArray = - byteArrayOf( - if (ne != EXPECTED_LENGTH_WILDCARD_EXTENDED) { - ne.toByte() - } else { - 0x0 - } - ) - -/** - * An APDU (Application Protocol Data Unit) Command per ISO/IEC 7816-4. - * Command APDU encoding options: - * - * ``` - * case 1: |CLA|INS|P1 |P2 | len = 4 - * case 2s: |CLA|INS|P1 |P2 |LE | len = 5 - * case 3s: |CLA|INS|P1 |P2 |LC |...BODY...| len = 6..260 - * case 4s: |CLA|INS|P1 |P2 |LC |...BODY...|LE | len = 7..261 - * case 2e: |CLA|INS|P1 |P2 |00 |LE1|LE2| len = 7 - * case 3e: |CLA|INS|P1 |P2 |00 |LC1|LC2|...BODY...| len = 8..65542 - * case 4e: |CLA|INS|P1 |P2 |00 |LC1|LC2|...BODY...|LE1|LE2| len =10..65544 - * - * LE, LE1, LE2 may be 0x00. - * LC must not be 0x00 and LC1|LC2 must not be 0x00|0x00 - * ``` - */ -class CommandApdu( - apduBytes: ByteArray, - val rawNc: Int, - val rawNe: Int?, - val dataOffset: Int -) { - private val _apduBytes = apduBytes.copyOf() - val bytes - get() = _apduBytes.copyOf() - - companion object { - fun ofOptions( - cla: Int, - ins: Int, - p1: Int, - p2: Int, - ne: Int? - ) = ofOptions(cla = cla, ins = ins, p1 = p1, p2 = p2, data = null, ne = ne) - - fun ofOptions( - cla: Int, - ins: Int, - p1: Int, - p2: Int, - data: ByteArray?, - ne: Int? - ): CommandApdu { - require(!(cla < 0 || ins < 0 || p1 < 0 || p2 < 0)) { "APDU header fields must not be less than 0" } - require(!(cla > 0xFF || ins > 0xFF || p1 > 0xFF || p2 > 0xFF)) { "APDU header fields must not be greater than 255 (0xFF)" } - ne?.let { require(ne <= EXPECTED_LENGTH_WILDCARD_EXTENDED || ne >= 0) { "APDU response length is out of bounds [0, 65536]" } } - - val bytes = ByteArrayOutputStream() - // write header |CLA|INS|P1 |P2 | - bytes.write(byteArrayOf(cla.toByte(), ins.toByte(), p1.toByte(), p2.toByte())) - - return if (data != null) { - val nc = data.size - require(nc <= 65535) { "ADPU cmd data length must not exceed 65535 bytes" } - - var dataOffset: Int - var le: Int? // le1, le2 - if (ne != null) { - le = ne - // case 4s or 4e - if (nc <= 255 && ne <= EXPECTED_LENGTH_WILDCARD_SHORT) { - // case 4s - dataOffset = 5 - bytes.write(encodeDataLengthShort(nc)) - bytes.write(data) - bytes.write(encodeExpectedLengthShort(ne)) - } else { - // case 4e - dataOffset = 7 - bytes.write(encodeDataLengthExtended(nc)) - bytes.write(data) - bytes.write(encodeExpectedLengthExtended(ne)) - } - } else { - // case 3s or 3e - le = null - if (nc <= 255) { - // case 3s - dataOffset = 5 - bytes.write(encodeDataLengthShort(nc)) - } else { - // case 3e - dataOffset = 7 - bytes.write(encodeDataLengthExtended(nc)) - } - bytes.write(data) - } - - CommandApdu( - apduBytes = bytes.toByteArray(), - rawNc = nc, - rawNe = le, - dataOffset = dataOffset - ) - } else { - // data empty - if (ne != null) { - // case 2s or 2e - if (ne <= EXPECTED_LENGTH_WILDCARD_SHORT) { - // case 2s - // 256 is encoded 0x0 - bytes.write(encodeExpectedLengthShort(ne)) - } else { - // case 2e - bytes.write(0x0) - bytes.write(encodeExpectedLengthExtended(ne)) - } - - CommandApdu( - apduBytes = bytes.toByteArray(), - rawNc = 0, - rawNe = ne, - dataOffset = 0 - ) - } else { - // case 1 - CommandApdu( - apduBytes = bytes.toByteArray(), - rawNc = 0, - rawNe = null, - dataOffset = 0 - ) - } - } - } - } -} - -/** - * APDU Response - */ -class ResponseApdu(apdu: ByteArray) { - init { - require(apdu.size >= 2) { "Response APDU must not have less than 2 bytes (status bytes SW1, SW2)" } - } - - private val apdu = apdu.copyOf() - - val nr: Int - get() = apdu.size - 2 - - val data: ByteArray - get() = apdu.copyOfRange(0, apdu.size - 2) - - val sw1: Int - get() = apdu[apdu.size - 2].toInt() and 0xFF - - val sw2: Int - get() = apdu[apdu.size - 1].toInt() and 0xFF - - val sw: Int - get() = sw1 shl 8 or sw2 - - val bytes: ByteArray - get() = apdu.copyOf() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as ResponseApdu - - if (!apdu.contentEquals(other.apdu)) return false - - return true - } - - override fun hashCode(): Int { - return apdu.contentHashCode() - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GeneralAuthenticateCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GeneralAuthenticateCommand.kt deleted file mode 100644 index c6c03f9d..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GeneralAuthenticateCommand.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import org.bouncycastle.asn1.ASN1EncodableVector -import org.bouncycastle.asn1.DERApplicationSpecific -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject - -private const val CLA_COMMAND_CHAINING = 0x10 -private const val CLA_NO_COMMAND_CHAINING = 0x00 -private const val INS = 0x86 -private const val NO_MEANING = 0x00 - -/** - * Commands representing the General Authenticate commands in gemSpec_COS#14.7.2 - */ - -/** - * UseCase: gemSpec_COS#14.7.2.1.1 PACE for end-user cards, Step 1 a - * - * @param commandChaining true for command chaining false if not - */ -fun HealthCardCommand.Companion.generalAuthenticate(commandChaining: Boolean) = - HealthCardCommand( - expectedStatus = generalAuthenticateStatus, - cla = if (commandChaining) CLA_COMMAND_CHAINING else CLA_NO_COMMAND_CHAINING, - ins = INS, - p1 = NO_MEANING, - p2 = NO_MEANING, - data = DERApplicationSpecific(28, ASN1EncodableVector()).encoded, - ne = NE_MAX_SHORT_LENGTH - ) - -/** - * UseCase: gemSpec_COS#14.7.2.1.1 PACE for end-user cards, Step 2a (tagNo 1), 3a (3) , 5a (5) - * - * @param commandChaining true for command chaining false if not - * @param data byteArray with data - */ -fun HealthCardCommand.Companion.generalAuthenticate( - commandChaining: Boolean, - data: ByteArray, - tagNo: Int -) = - HealthCardCommand( - expectedStatus = generalAuthenticateStatus, - cla = if (commandChaining) CLA_COMMAND_CHAINING else CLA_NO_COMMAND_CHAINING, - ins = INS, - p1 = NO_MEANING, - p2 = NO_MEANING, - data = DERApplicationSpecific( - 28, - DERTaggedObject(false, tagNo, DEROctetString(data)) - ).encoded, - ne = NE_MAX_SHORT_LENGTH - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GetPinStatusCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GetPinStatusCommand.kt deleted file mode 100644 index 0eb5774a..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GetPinStatusCommand.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.Password - -/** - * Command representing Get Pin Status Command gemSpec_COS#14.6.4 - */ - -private const val CLA = 0x80 -private const val INS = 0x20 -private const val NO_MEANING = 0x00 - -/** - * Use case Get Pin Status gemSpec_COS#14.6.4.1 - * - * @param password the arguments for the Get Pin Status command - * @param dfSpecific whether or not the password object specifies a Global or DF-specific. - * true = DF-Specific, false = global - */ -fun HealthCardCommand.Companion.getPinStatus(password: Password, dfSpecific: Boolean) = - HealthCardCommand( - expectedStatus = pinStatus, - cla = CLA, - ins = INS, - p1 = NO_MEANING, - p2 = password.calculateKeyReference(dfSpecific) - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/HealthCardCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/HealthCardCommand.kt deleted file mode 100644 index 30260374..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/HealthCardCommand.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.ICardChannel -import timber.log.Timber - -private const val HEX_FF = 0xff - -const val NE_MAX_EXTENDED_LENGTH = 65536 -const val NE_MAX_SHORT_LENGTH = 256 -const val EXPECT_ALL_WILDCARD = -1 - -/** - * Superclass for all HealthCardCommands - */ -class HealthCardCommand( - val expectedStatus: Map, - val cla: Int, - val ins: Int, - val p1: Int = 0, - val p2: Int = 0, - val data: ByteArray? = null, - val ne: Int? = null -) { - init { - require(!(cla > HEX_FF || ins > HEX_FF || p1 > HEX_FF || p2 > HEX_FF)) { "Parameter value exceeds one byte" } - } - - /** - * {@inheritDoc} - * - * @param iHealthCard - * health card to execute the command - * - * @return result operation - */ - fun executeOn(channel: ICardChannel): HealthCardResponse { - val cApdu = getCommandApdu(channel) - return channel.transmit(cApdu).let { - HealthCardResponse(expectedStatus[it.sw] ?: ResponseStatus.UNKNOWN_STATUS, it) - } - } - - private fun getCommandApdu(channel: ICardChannel): CommandApdu { - val expectedLength = if (ne != null && ne == EXPECT_ALL_WILDCARD) { - if (channel.isExtendedLengthSupported) { - NE_MAX_EXTENDED_LENGTH - } else { - NE_MAX_SHORT_LENGTH - } - } else { - ne - } - - val commandAPDU = CommandApdu.ofOptions(cla, ins, p1, p2, data, expectedLength) - - val apduLength = commandAPDU.bytes.size - require(apduLength <= channel.maxTransceiveLength) { - "CommandApdu is too long to send. Limit for Reader is " + channel.maxTransceiveLength + - " but length of commandApdu is " + apduLength - } - return commandAPDU - } - - // keep for extension functions - companion object -} - -class HealthCardResponse(val status: ResponseStatus, val apdu: ResponseApdu) - -fun HealthCardCommand.executeSuccessfulOn(channel: ICardChannel): HealthCardResponse = - this.executeOn(channel).also { - Timber.d("response status: %s", it.status) - it.requireSuccess() - } - -class ResponseException(val responseStatus: ResponseStatus) : Exception() - -fun HealthCardResponse.requireSuccess() { - if (this.status != ResponseStatus.SUCCESS) { - throw ResponseException(this.status) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ManageSecurityEnvironmentCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ManageSecurityEnvironmentCommand.kt deleted file mode 100644 index 3e403de3..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ManageSecurityEnvironmentCommand.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.CardKey -import de.gematik.ti.erp.app.cardwall.model.nfc.card.PsoAlgorithm -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject - -/** - * Commands representing Manage Security Environment command in gemSpec_COS#14.9.9 - */ - -private const val CLA = 0x00 -private const val INS = 0x22 -private const val MODE_SET_SECRET_KEY_OBJECT_P1 = 0xC1 -private const val MODE_AFFECTED_LIST_ELEMENT_IS_EXT_AUTH_P2 = 0xA4 -private const val MODE_SET_PRIVATE_KEY_P1 = 0x41 -private const val MODE_AFFECTED_LIST_ELEMENT_IS_SIGNATURE_CREATION = 0xB6 - -/** - * Use case Key Selection for symmetric card connection without curves gemSpec_COS#14.9.9.7 - */ -fun HealthCardCommand.Companion.manageSecEnvWithoutCurves( - cardKey: CardKey, - dfSpecific: Boolean, - oid: ByteArray?, -) = - HealthCardCommand( - expectedStatus = manageSecurityEnvironmentStatus, - cla = CLA, - ins = INS, - p1 = MODE_SET_SECRET_KEY_OBJECT_P1, - p2 = MODE_AFFECTED_LIST_ELEMENT_IS_EXT_AUTH_P2, - data = - // '80 I2OS(OctetLength(OID), 1) || OID || 83 01 || keyRef' - DERTaggedObject(false, 0, DEROctetString(oid)).encoded + - DERTaggedObject( - false, - 3, - DEROctetString(byteArrayOf(cardKey.calculateKeyReference(dfSpecific).toByte())) - ).encoded - ) - -/** - * Use cases Key Selection for authentication and encryption gemSpec_COS#14.9.9.9 - */ -fun HealthCardCommand.Companion.manageSecEnvForSigning( - psoAlgorithm: PsoAlgorithm, - key: CardKey, - dfSpecific: Boolean -) = - HealthCardCommand( - expectedStatus = manageSecurityEnvironmentStatus, - cla = CLA, - ins = INS, - p1 = MODE_SET_PRIVATE_KEY_P1, - p2 = MODE_AFFECTED_LIST_ELEMENT_IS_SIGNATURE_CREATION, - data = - // '8401||keyRef||8001 algId' - DERTaggedObject( - false, - 4, - DEROctetString(byteArrayOf(key.calculateKeyReference(dfSpecific).toByte())) - ).encoded + - DERTaggedObject( - false, - 0, - DEROctetString(byteArrayOf(psoAlgorithm.identifier.toByte())) - ).encoded - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/PsoComputeDigitalSignatureCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/PsoComputeDigitalSignatureCommand.kt deleted file mode 100644 index 9403f09e..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/PsoComputeDigitalSignatureCommand.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -private const val CLA = 0x00 -private const val INS = 0x2A - -/** - * Commands representing Compute Digital Signature in gemSpec_COS#14.8.2 - */ -fun HealthCardCommand.Companion.psoComputeDigitalSignature( - dataToBeSigned: ByteArray -) = - HealthCardCommand( - expectedStatus = psoComputeDigitalSignatureStatus, - cla = CLA, - ins = INS, - p1 = 0x9E, - p2 = 0x9A, - data = dataToBeSigned, - ne = EXPECT_ALL_WILDCARD - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ReadCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ReadCommand.kt deleted file mode 100644 index 9133fb15..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ReadCommand.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ShortFileIdentifier - -private const val CLA = 0x00 -private const val INS = 0xB0 -private const val BYTE_MODULO = 256 -private const val SFI_MARKER = 0x80 -private const val MIN_OFFSET_RANGE = 0 -private const val MAX_OFFSET_WITHOUT_SFI_RANGE = 0x7FFF -private const val MAX_OFFSET_WITH_SFI_RANGE = 255 - -/** - * Commands representing the Read Binary command in gemSpec_COS#14.3.2 - */ - -/** - * Calls ReadCommand(0x00, EXPECT_ALL_WILDCARD) - */ -fun HealthCardCommand.Companion.read() = - HealthCardCommand.read(0x00, EXPECT_ALL_WILDCARD) - -/** - * Calls ReadCommand(offset, EXPECT_ALL_WILDCARD) - */ -fun HealthCardCommand.Companion.read(offset: Int) = - HealthCardCommand.read(offset, EXPECT_ALL_WILDCARD) - -/** - * Use case Read Binary without ShortFileIdentifier gemSpec_COS#14.3.2.1 - */ -fun HealthCardCommand.Companion.read(offset: Int, ne: Int): HealthCardCommand { - require(offset in MIN_OFFSET_RANGE..MAX_OFFSET_WITHOUT_SFI_RANGE) - - val p2 = offset % BYTE_MODULO - val p1 = (offset - p2) / BYTE_MODULO - - return HealthCardCommand( - expectedStatus = readStatus, - cla = CLA, - ins = INS, - p1 = p1, - p2 = p2, - ne = ne - ) -} - -/** - * Calls ReadCommand(sfi, 0x00, EXPECT_ALL_WILDCARD) - */ -fun HealthCardCommand.Companion.read(sfi: ShortFileIdentifier) = - HealthCardCommand.read(sfi, 0x00, EXPECT_ALL_WILDCARD) - -/** - * Calls ReadCommand(sfi, offset, EXPECT_ALL_WILDCARD) - */ -fun HealthCardCommand.Companion.read(sfi: ShortFileIdentifier, offset: Int) = - HealthCardCommand.read(sfi, offset, EXPECT_ALL_WILDCARD) - -/** - * Use case Read Binary with ShortFileIdentifier gemSpec_COS#14.3.2.2 - */ -fun HealthCardCommand.Companion.read(sfi: ShortFileIdentifier, offset: Int, ne: Int): HealthCardCommand { - require(offset in MIN_OFFSET_RANGE..MAX_OFFSET_WITH_SFI_RANGE) - - return HealthCardCommand( - expectedStatus = readStatus, - cla = CLA, - ins = INS, - p1 = SFI_MARKER + sfi.sfId, - p2 = offset, - ne = ne - ) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/SelectCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/SelectCommand.kt deleted file mode 100644 index cd1f8458..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/SelectCommand.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.command - -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ApplicationIdentifier -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.FileIdentifier - -private const val CLA = 0x00 -private const val INS = 0xA4 -private const val SELECTION_MODE_DF_BY_FID = 0x01 -private const val SELECTION_MODE_EF_BY_FID = 0x02 -private const val SELECTION_MODE_PARENT = 0x03 -private const val SELECTION_MODE_AID = 0x04 -private const val RESPONSE_TYPE_NO_RESPONSE = 0x0C -private const val RESPONSE_TYPE_FCP = 0x04 -private const val FILE_OCCURRENCE_FIRST = 0x00 -private const val FILE_OCCURRENCE_NEXT = 0x02 -private const val P2_FCP = 0x04 -private const val P2 = 0x0C - -// Note: Left out use case Select parent folder requesting File Control Parameter gemSpec_Cos#14.2.6.12 -// Note: Left out use case Select parent folder requesting File Control Parameter gemSpec_Cos#14.2.6.14 -private fun calculateP2(requestFCP: Boolean, nextOccurrence: Boolean): Int = - if (requestFCP) { - RESPONSE_TYPE_FCP - } else { - RESPONSE_TYPE_NO_RESPONSE - } + if (nextOccurrence) { - FILE_OCCURRENCE_NEXT - } else { - FILE_OCCURRENCE_FIRST - } - -/** - * Commands representing Select Command gemSpec_COS#14.2.6 - */ - -/** - * Use case Select root of object system gemSpec_Cos#14.2.6.1 + use case Select parent folder gemSpec_Cos#14.2.6.11 - * Use case Select root of object system requesting File Control Parameter gemSpec_Cos#14.2.6.2 with Parameter readFirst true - * - * @param selectParentElseRoot if true SELECTION_MODE_PARENT else SELECTION_MODE_AID - * @param readFirst if true read FCP else only select - */ -fun HealthCardCommand.Companion.select(selectParentElseRoot: Boolean, readFirst: Boolean) = - HealthCardCommand( - expectedStatus = selectStatus, - cla = CLA, - ins = INS, - p1 = if (selectParentElseRoot) SELECTION_MODE_PARENT else SELECTION_MODE_AID, - p2 = calculateP2(readFirst, false), - ne = if (readFirst) EXPECT_ALL_WILDCARD else null - ) - -// Note: Left out use cases Select without Application Identifier, next gemSpec_Cos#14.2.6.3 - 14.2.6.4 -/** - * Use case Select file with Application Identifier, first occurrence, no File Control Parameter gemSpec_Cos#14.2.6.5 - * - * @param aid - */ -fun HealthCardCommand.Companion.select(aid: ApplicationIdentifier) = - HealthCardCommand.select( - aid, - selectNextElseFirstOccurrence = false, - requestFcp = false, - fcpLength = 0 - ) - -/** - * Use cases Select file with Application Identifier gemSpec_Cos#14.2.6.5 - 14.2.6.8 - * - * @param fcpLength determine expected size of response if File Control Parameter requested - */ -fun HealthCardCommand.Companion.select( - aid: ApplicationIdentifier, - selectNextElseFirstOccurrence: Boolean, - requestFcp: Boolean, - fcpLength: Int -) = - HealthCardCommand( - expectedStatus = selectStatus, - cla = CLA, - ins = INS, - p1 = SELECTION_MODE_AID, - p2 = calculateP2(requestFcp, selectNextElseFirstOccurrence), - data = aid.aid, - ne = if (requestFcp) fcpLength else null - ) - -/** - * Use case Select DF with File Identifier gemSpec_Cos#14.2.6.9 and - * use case Select EF with File Identifier gemSpec_Cos#14.2.6.13 - */ -fun HealthCardCommand.Companion.select(fid: FileIdentifier, selectDfElseEf: Boolean) = - HealthCardCommand.select(fid, selectDfElseEf, false, 0) - -/** - * Use cases Select DF with File Identifier gemSpec_Cos#14.2.6.9 - 14.2.6.10 and - * use cases Select EF with File Identifier gemSpec_Cos#14.2.6.13 - 14.2.6.14 - * - * @param selectDfElseEf true if Dedicated File shall be selected, false if Elementary File shall be selected - * @param fcpLength determine expected size of response if File Control Parameter requested - */ -fun HealthCardCommand.Companion.select( - fid: FileIdentifier, - selectDfElseEf: Boolean, - requestFcp: Boolean, - fcpLength: Int -) = - HealthCardCommand( - expectedStatus = selectStatus, - cla = CLA, - ins = INS, - p1 = if (selectDfElseEf) SELECTION_MODE_DF_BY_FID else SELECTION_MODE_EF_BY_FID, - p2 = if (requestFcp) P2_FCP else P2, - data = fid.getFid(), - ne = if (requestFcp) fcpLength else null - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/CertificateExchange.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/CertificateExchange.kt deleted file mode 100644 index 24703e0c..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/CertificateExchange.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.exchange - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects.Df -import de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects.Mf -import de.gematik.ti.erp.app.cardwall.model.nfc.command.EXPECTED_LENGTH_WILDCARD_EXTENDED -import de.gematik.ti.erp.app.cardwall.model.nfc.command.HealthCardCommand -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseStatus -import de.gematik.ti.erp.app.cardwall.model.nfc.command.executeSuccessfulOn -import de.gematik.ti.erp.app.cardwall.model.nfc.command.read -import de.gematik.ti.erp.app.cardwall.model.nfc.command.select -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ApplicationIdentifier -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.FileIdentifier -import timber.log.Timber -import java.io.ByteArrayOutputStream - -fun NfcCardSecureChannel.retrieveCertificate(): ByteArray { - HealthCardCommand.select(ApplicationIdentifier(Df.Esign.AID)).executeSuccessfulOn(this) - HealthCardCommand.select( - FileIdentifier(Mf.Df.Esign.Ef.CchAutE256.FID), - selectDfElseEf = false, - requestFcp = true, - fcpLength = EXPECTED_LENGTH_WILDCARD_EXTENDED - ).executeSuccessfulOn(this) - - val buffer = ByteArrayOutputStream() - var offset = 0 - while (true) { - val response = HealthCardCommand.read(offset) - .executeOn(this) - - Timber.d("Response was %s", response.status) - - val data = response.apdu.data - Timber.d("Read %d bytes. Offset %d", data.size, offset) - - if (data.isNotEmpty()) { - buffer.write(data) - offset += data.size - } - - when (response.status) { - ResponseStatus.SUCCESS -> { } - ResponseStatus.END_OF_FILE_WARNING, - ResponseStatus.OFFSET_TOO_BIG -> break - else -> error("Couldn't read certificate: ${response.status}") - } - } - - return buffer.toByteArray() -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/KeyDerivationFunction.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/KeyDerivationFunction.kt deleted file mode 100644 index ae079668..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/KeyDerivationFunction.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.exchange - -import org.bouncycastle.crypto.digests.SHA1Digest - -private const val CHECKSUMLENGTH = 20 -private const val AES128LENGTH = 16 -private const val OFFSETLENGTH = 4 -private const val ENCLASTBYTE = 1 -private const val MACLASTBYTE = 2 -private const val PASSWORDLASTBYTE = 3 - -/** - * This class provides functionality to derive AES-128 keys. - */ -object KeyDerivationFunction { - /** - * derive AES-128 key - * - * @param sharedSecretK byte array with shared secret value. - * @param mode key derivation for ENC, MAC or derivation from password - * @return byte array with AES-128 key - */ - fun getAES128Key(sharedSecretK: ByteArray, mode: Mode): ByteArray { - val checksum = ByteArray(CHECKSUMLENGTH) - val data = replaceLastKeyByte(sharedSecretK, mode) - SHA1Digest().apply { - update(data, 0, data.size) - doFinal(checksum, 0) - } - return checksum.copyOf(AES128LENGTH) - } - - private fun replaceLastKeyByte(key: ByteArray, mode: Mode): ByteArray = - ByteArray(key.size + OFFSETLENGTH).apply { - key.copyInto(this) - this[this.size - 1] = when (mode) { - Mode.ENC -> ENCLASTBYTE.toByte() - Mode.MAC -> MACLASTBYTE.toByte() - Mode.PASSWORD -> PASSWORDLASTBYTE.toByte() - } - } - - enum class Mode { - ENC, // key for encryption/decryption - MAC, // key for MAC - PASSWORD // encryption keys from a password - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/PaceInfo.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/PaceInfo.kt deleted file mode 100644 index 41d28fd4..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/PaceInfo.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.exchange - -import de.gematik.ti.erp.app.cardwall.model.nfc.CardUtilities -import org.bouncycastle.asn1.ASN1InputStream -import org.bouncycastle.asn1.ASN1Integer -import org.bouncycastle.asn1.ASN1ObjectIdentifier -import org.bouncycastle.asn1.ASN1Sequence -import org.bouncycastle.asn1.DLSet -import org.bouncycastle.jce.ECNamedCurveTable - -private const val PARAMETER256 = 13 -private const val PARAMETER384 = 16 -private const val PARAMETER512 = 17 - -/** - * Extracts PACE Information from CardAccess - */ -class PaceInfo(cardAccess: ByteArray) { - private val protocol: ASN1ObjectIdentifier - private val parameterID: Int - - /** - * Returns PACE info protocol bytes - */ - val paceInfoProtocolBytes: ByteArray = - ASN1InputStream(cardAccess).use { asn1InputStream -> - val app = asn1InputStream.readObject() as DLSet - val seq = app.getObjectAt(0) as ASN1Sequence - protocol = seq.getObjectAt(0) as ASN1ObjectIdentifier - parameterID = (seq.getObjectAt(2) as ASN1Integer).value.toInt() - - protocol.encoded.let { - it.copyOfRange(2, it.size) - } - } - - /** - * PACE info protocol ID - */ - val protocolID: String = protocol.id - - private val ecNamedCurveParameterSpec = ECNamedCurveTable.getParameterSpec( - when (parameterID) { - PARAMETER256 -> "BrainpoolP256r1" - PARAMETER384 -> "BrainpoolP384r1" - PARAMETER512 -> "BrainpoolP512r1" - else -> "" - } - ) - - val ecCurve = ecNamedCurveParameterSpec.curve - val ecPointG = ecNamedCurveParameterSpec.g - - fun convertECPoint(ecPoint: ByteArray) = - CardUtilities.byteArrayToECPoint(ecPoint, ecCurve) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/PinExchange.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/PinExchange.kt deleted file mode 100644 index 9cfc56ec..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/PinExchange.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.exchange - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.EncryptedPinFormat2 -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.card.Password -import de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects.Mf -import de.gematik.ti.erp.app.cardwall.model.nfc.command.HealthCardCommand -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseStatus -import de.gematik.ti.erp.app.cardwall.model.nfc.command.executeSuccessfulOn -import de.gematik.ti.erp.app.cardwall.model.nfc.command.select -import de.gematik.ti.erp.app.cardwall.model.nfc.command.verifyPin -import timber.log.Timber - -fun NfcCardSecureChannel.verifyPin(pin: String): ResponseStatus { - HealthCardCommand.select(selectParentElseRoot = false, readFirst = false) - .executeSuccessfulOn(this) - - val password = Password(Mf.MrPinHome.PWID) - - Timber.d("Verify pin") - - val response = - HealthCardCommand.verifyPin(password, false, EncryptedPinFormat2(pin)) - .executeOn(this) - - require( - when (response.status) { - ResponseStatus.SUCCESS, - ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, - ResponseStatus.WRONG_SECRET_WARNING_COUNT_02, - ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> - true - else -> - false - } - ) { "Verify pin command failed with status: ${response.status}" } - - Timber.d("Pin verified with status %s", response.status) - - return response.status -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/SignChallengeExchange.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/SignChallengeExchange.kt deleted file mode 100644 index 477a07fa..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/SignChallengeExchange.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.exchange - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.CardKey -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.card.PsoAlgorithm -import de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects.Df -import de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects.Mf -import de.gematik.ti.erp.app.cardwall.model.nfc.command.HealthCardCommand -import de.gematik.ti.erp.app.cardwall.model.nfc.command.executeSuccessfulOn -import de.gematik.ti.erp.app.cardwall.model.nfc.command.manageSecEnvForSigning -import de.gematik.ti.erp.app.cardwall.model.nfc.command.psoComputeDigitalSignature -import de.gematik.ti.erp.app.cardwall.model.nfc.command.select -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ApplicationIdentifier - -fun NfcCardSecureChannel.signChallenge(challenge: ByteArray): ByteArray { - HealthCardCommand.select(ApplicationIdentifier(Df.Esign.AID)).executeSuccessfulOn(this) - - HealthCardCommand.manageSecEnvForSigning( - PsoAlgorithm.SIGN_VERIFY_ECDSA, - CardKey(Mf.Df.Esign.PrK.ChAutE256.KID), true - ).executeSuccessfulOn(this) - - return HealthCardCommand.psoComputeDigitalSignature(challenge) - .executeSuccessfulOn(this) - .apdu.data -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ApplicationIdentifier.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ApplicationIdentifier.kt deleted file mode 100644 index dce61f27..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ApplicationIdentifier.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.identifier - -import org.bouncycastle.util.encoders.Hex - -private const val AID_MIN_LENGTH = 5 -private const val AID_MAX_LENGTH = 16 - -/** - * An application identifier (AID) is used to address an application on the card - */ -class ApplicationIdentifier(aid: ByteArray) { - val aid: ByteArray = aid.copyOf() - get() = - field.copyOf() - - init { - require(!(aid.size < AID_MIN_LENGTH || aid.size > AID_MAX_LENGTH)) { - // gemSpec_COS#N010.200 - String.format( - "Application File Identifier length out of valid range [%d,%d]", - AID_MIN_LENGTH, - AID_MAX_LENGTH - ) - } - } - - constructor(hexAid: String) : this(Hex.decode(hexAid)) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/FileIdentifier.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/FileIdentifier.kt deleted file mode 100644 index c33e916c..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/FileIdentifier.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.identifier - -import org.bouncycastle.util.encoders.Hex -import java.nio.ByteBuffer - -/** - * A file identifier may reference any file. It consists of two bytes. The value '3F00' - * is reserved for referencing the MF. The value 'FFFF' is reserved for future use. The value '3FFF' is reserved - * (see below and 7.4.1). The value '0000' is reserved (see 7.2.2 and 7.4.1). In order to unambiguously select - * any file by its identifier, all EFs and DFs immediately under a given DF shall have different file identifiers. - * @see "ISO/IEC 7816-4" - */ -class FileIdentifier { - private val fid: Int - - constructor(fid: ByteArray) { - require(fid.size == 2) { "requested length of byte array for a File Identifier value is 2 but was " + fid.size } - val b = ByteBuffer.allocate(Int.SIZE_BYTES) - for (i in fid.indices) { - b.put(fid.size + i, fid[i]) - } - this.fid = b.int - sanityCheck() - } - - constructor(fid: Int) { - this.fid = fid - sanityCheck() - } - - constructor(hexFid: String) : this(Hex.decode(hexFid)) - - fun getFid(): ByteArray { - val buffer = ByteBuffer.allocate(Short.SIZE_BYTES) - return buffer.putShort(fid.toShort()).array() - } - - private fun sanityCheck() { - // gemSpec_COS#N006.700, N006.900 - require(!((fid < 0x1000 || fid > 0xFEFF) && fid != 0x011C || fid == 0x3FFF)) { - "File Identifier is out of range: 0x" + Hex.toHexString(getFid()) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ShortFileIdentifier.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ShortFileIdentifier.kt deleted file mode 100644 index 88d740d0..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ShortFileIdentifier.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.model.nfc.identifier - -import okio.ByteString.Companion.decodeHex - -/** - * It is possible that the attribute type shortFileIdentifier is used by the file object types. - * Short file identifiers are used for implicit file selection in the immediate context of a command. - * The value of shortFileIdentifier MUST be an integer in the interval [1, 30] - * - * @see "ISO/IEC7816-4 und gemSpec_COS 'Spezifikation des Card Operating System'" - */ -private const val MIN_VALUE = 1 -private const val MAX_VALUE = 30 - -class ShortFileIdentifier(val sfId: Int) { - init { - sanityCheck() - } - - constructor(hexSfId: String) : this(hexSfId.decodeHex().toByteArray()[0].toInt()) - - private fun sanityCheck() { - require(!(sfId < MIN_VALUE || sfId > MAX_VALUE)) { - - // gemSpec_COS#N007.000 - String.format( - "Short File Identifier out of valid range [%d,%d]", - MIN_VALUE, - MAX_VALUE - ) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/AltPairing.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/AltPairing.kt new file mode 100644 index 00000000..45e94dbf --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/AltPairing.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.ui + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.secureRandomInstance +import io.github.aakira.napier.Napier +import kotlinx.coroutines.suspendCancellableCoroutine +import org.bouncycastle.util.encoders.Base64 +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.KeyStoreException +import java.security.PublicKey +import java.security.spec.ECGenParameterSpec +import kotlin.coroutines.resume + +private const val KeyStoreAliasKeySize = 32 // in bytes +private const val KeyTimeout = 15 * 60 // in seconds + +@Stable +class AltPairingProvider( + private val activity: FragmentActivity, + private val promptInfo: BiometricPrompt.PromptInfo +) { + private val executor = ContextCompat.getMainExecutor(activity) + + sealed interface AuthResult { + object Error : AuthResult + object Authenticated : AuthResult + + @Stable + class Initialized(val aliasOfSecureElementEntry: ByteArray, val publicKey: PublicKey) : AuthResult + } + + @RequiresApi(Build.VERSION_CODES.P) + suspend fun initializeAndPrompt(): AuthResult = suspendCancellableCoroutine { continuation -> + val aliasOfSecureElementEntry = ByteArray(KeyStoreAliasKeySize).apply { + secureRandomInstance().nextBytes(this) + } + + val keyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore" + ) + + val parameterSpec = KeyGenParameterSpec.Builder( + Base64.toBase64String(aliasOfSecureElementEntry), + KeyProperties.PURPOSE_SIGN + ).apply { + setInvalidatedByBiometricEnrollment(true) + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // While the documentation of Android suggests to set this to zero, this is safe to use. + // If the key is used and later, e.g. a fingerprint is added, the keystore implementation of + // Android will throw a `KeyPermanentlyInvalidatedException`. Later on if the user restarts + // the phone, the key is permanently invalidated and the actual `UserNotAuthenticatedException` + // is thrown. + setUserAuthenticationParameters(KeyTimeout, KeyProperties.AUTH_BIOMETRIC_STRONG) + } + setIsStrongBoxBacked(true) + setDigests(KeyProperties.DIGEST_SHA256) + + setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + }.build() + + keyPairGenerator.initialize(parameterSpec) + val keyPair = keyPairGenerator.generateKeyPair() + val publicKey = keyPair.public // required to init + + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + + continuation.resume( + AuthResult.Initialized( + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + publicKey = publicKey + ) + ) + } + + override fun onAuthenticationError( + errCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError(errCode, errString) + + Napier.e("Failed to authenticate: $errString") + + cleanup(aliasOfSecureElementEntry) + continuation.resume(AuthResult.Error) + } + } + ) + + prompt.authenticate(promptInfo) + + continuation.invokeOnCancellation { + prompt.cancelAuthentication() + cleanup(aliasOfSecureElementEntry) + } + } + + fun cleanup(aliasOfSecureElementEntry: ByteArray) { + try { + KeyStore.getInstance("AndroidKeyStore") + .apply { load(null) } + .deleteEntry(Base64.toBase64String(aliasOfSecureElementEntry)) + } catch (e: KeyStoreException) { + Napier.e("Couldn't remove key from keystore on failure; expected to happen.", e) + } + } +} + +@Composable +fun rememberAltPairing(): AltPairingProvider { + val activity = LocalContext.current as FragmentActivity + val title = stringResource(R.string.alternate_auth_header) + val description = stringResource(R.string.alternate_auth_info) + val negativeButton = stringResource(R.string.cancel) + val promptInfo = remember { + BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setDescription(description) + .setNegativeButtonText(negativeButton) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG + ) + .build() + } + return remember { + AltPairingProvider(activity, promptInfo) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAccessNumber.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAccessNumber.kt new file mode 100644 index 00000000..9f392898 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAccessNumber.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.pharmacy.ui.scrollOnFocus +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.ClickableTaggedText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import de.gematik.ti.erp.app.utils.compose.annotatedLinkStringLight + +const val EXPECTED_CAN_LENGTH = 6 + +@Composable +fun CardAccessNumber( + onClickLearnMore: () -> Unit, + can: String, + screenTitle: String, + onCanChange: (String) -> Unit, + onNext: () -> Unit, + nextText: String? = null, + onCancel: () -> Unit +) { + val lazyListState = rememberLazyListState() + + CardHandlingScaffold( + modifier = Modifier.testTag("cardWall/cardAccessNumber"), + backMode = NavigationBarMode.Back, + title = screenTitle, + nextEnabled = can.length == EXPECTED_CAN_LENGTH, + onNext = { onNext() }, + listState = lazyListState, + nextText = nextText ?: stringResource(R.string.cdw_next), + actions = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) { innerPadding -> + val contentPadding by derivedStateOf { + PaddingValues( + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + } + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding + ) { + item { + HealthCardCanImage() + } + item { + CanDescription(onClickLearnMore) + SpacerXXLarge() + } + item { + CanInputField( + modifier = Modifier.scrollOnFocus(to = 2, lazyListState), + can = can, + onCanChange = onCanChange, + next = onNext + ) + } + } + } +} + +@Composable +fun HealthCardCanImage() { + Column(modifier = Modifier.wrapContentHeight()) { + Image( + painterResource(R.drawable.card_wall_card_can), + null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + SpacerXXLarge() + } +} + +@Composable +fun CanDescription(onClickLearnMore: () -> Unit) { + Column { + Text( + stringResource(R.string.cdw_can_headline), + style = AppTheme.typography.h5 + ) + + SpacerSmall() + + Text( + stringResource(R.string.cdw_can_description), + style = AppTheme.typography.body1 + ) + + SpacerSmall() + + ClickableTaggedText( + text = annotatedLinkStringLight( + uri = "", + text = stringResource(R.string.cdw_no_can_on_card) + ), + onClick = { onClickLearnMore() }, + style = AppTheme.typography.body2, + modifier = Modifier.align(Alignment.End) + ) + } +} + +@Composable +fun CanInputField( + modifier: Modifier, + can: String, + onCanChange: (String) -> Unit, + next: () -> Unit +) { + val canRegex = """^\d{0,6}$""".toRegex() + + OutlinedTextField( + modifier = modifier + .testTag(TestTag.CardWall.CAN.CANField) + .fillMaxWidth(), + value = can, + onValueChange = { + if (it.matches(canRegex)) { + onCanChange(it) + } + }, + label = { Text(stringResource(R.string.can_input_field_label)) }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next + ), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedLabelColor = AppTheme.colors.neutral400, + placeholderColor = AppTheme.colors.neutral400, + trailingIconColor = AppTheme.colors.neutral400 + ), + keyboardActions = KeyboardActions { + if (can.length == EXPECTED_CAN_LENGTH) { + next() + } + } + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAuthDialog.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAuthDialog.kt index c0f8aafb..42972f3f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAuthDialog.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAuthDialog.kt @@ -49,7 +49,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -87,23 +86,26 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import com.google.accompanist.insets.systemBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.ui.platform.LocalContext import de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.NfcNotEnabledException import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.R.string -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData +import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState import de.gematik.ti.erp.app.core.LocalActivity +import de.gematik.ti.erp.app.core.LocalAnalytics +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.analytics.Analytics +import de.gematik.ti.erp.app.analytics.Analytics.AuthenticationProblem import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog import de.gematik.ti.erp.app.utils.compose.Dialog import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerTiny import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource -import java.util.Locale -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.cancellation.CancellationException +import de.gematik.ti.erp.app.utils.compose.handleIntent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -112,10 +114,13 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import timber.log.Timber +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.retryWhen +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException private enum class HealthCardAnimationState { START, @@ -139,8 +144,8 @@ fun rememberCardWallAuthenticationDialogState(): CardWallAuthenticationDialogSta return remember { CardWallAuthenticationDialogState() } } +@Suppress("LongMethod", "ComplexMethod") @OptIn( - ExperimentalMaterialApi::class, ExperimentalAnimationApi::class, ExperimentalCoroutinesApi::class ) @@ -148,14 +153,14 @@ fun rememberCardWallAuthenticationDialogState(): CardWallAuthenticationDialogSta fun CardWallAuthenticationDialog( dialogState: CardWallAuthenticationDialogState = rememberCardWallAuthenticationDialogState(), viewModel: CardWallViewModel, - authenticationMethod: CardWallData.AuthenticationMethod, - cardAccessNumber: String, - personalIdentificationNumber: String, + authenticationData: CardWallAuthenticationData, + profileId: ProfileIdentifier, troubleShootingEnabled: Boolean = false, allowUserCancellation: Boolean = true, onFinal: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit, onClickTroubleshooting: (() -> Unit)? = null, onStateChange: ((AuthenticationState) -> Unit)? = null ) { @@ -166,21 +171,27 @@ fun CardWallAuthenticationDialog( var showEnableNfcDialog by remember { mutableStateOf(false) } var errorCount by remember(troubleShootingEnabled) { mutableStateOf(0) } + val tracker = LocalAnalytics.current + + var cancelEnabled by remember { mutableStateOf(true) } val state by produceState(initialValue = AuthenticationState.None) { toggleAuth.transformLatest { emit(AuthenticationState.None) + cancelEnabled = true when (it) { is ToggleAuth.ToggleByUser -> { - if (it.value && !viewModel.isNFCEnabled()) { - showEnableNfcDialog = true - value = AuthenticationState.None - } else if (it.value) { + if (it.value) { emitAll( viewModel.doAuthentication( - can = cardAccessNumber, - pin = personalIdentificationNumber, - method = authenticationMethod, - activity.nfcTagFlow + profileId = profileId, + authenticationData = authenticationData, + activity + .nfcTagFlow + .catch { + if (it is NfcNotEnabledException) { + showEnableNfcDialog = true + } + } ) ) } else { @@ -189,7 +200,7 @@ fun CardWallAuthenticationDialog( } is ToggleAuth.ToggleByHealthCard -> { val collectedOnce = AtomicBoolean(false) - val f = flow { + val tagFlow = flow { if (collectedOnce.get()) { activity.nfcTagFlow.collect { emit(it) @@ -201,16 +212,15 @@ fun CardWallAuthenticationDialog( } emitAll( viewModel.doAuthentication( - can = cardAccessNumber, - pin = personalIdentificationNumber, - method = authenticationMethod, - f + profileId = profileId, + authenticationData = authenticationData, + tagFlow ) ) } } }.catch { - Timber.e(it, "Something unforeseen happened") + Napier.e("Something unforeseen happened", it) // if this happens we can't recover from here emit(AuthenticationState.HealthCardCommunicationInterrupted) delay(1000) @@ -221,11 +231,21 @@ fun CardWallAuthenticationDialog( }.collect { errorCount += if (it == AuthenticationState.HealthCardCommunicationInterrupted) 1 else 0 value = it + + tracker.trackAuth(it) } } LaunchedEffect(Unit) { - activity.nfcTagFlow.retry() + activity.nfcTagFlow + .retryWhen { cause, _ -> + cause !is NfcNotEnabledException + } + .catch { cause -> + if (cause is NfcNotEnabledException) { + showEnableNfcDialog = true + } + } .filter { // only let interrupted communications through !(state.isFailure() && state != AuthenticationState.HealthCardCommunicationInterrupted) @@ -244,6 +264,7 @@ fun CardWallAuthenticationDialog( state.isInProgress() -> showAuthDialog = true state.isReady() -> showAuthDialog = false state.isFinal() -> { + tracker.trackIdentifiedWithIDP() onFinal() } } @@ -253,9 +274,11 @@ fun CardWallAuthenticationDialog( AuthenticationDialog( state = state, showTroubleshooting = troubleShootingEnabled && errorCount > 2 && !state.isInProgress(), + onCancelEnabled = allowUserCancellation && cancelEnabled, onCancel = { - if (allowUserCancellation) { - coroutineScope.launch { toggleAuth.emit(ToggleAuth.ToggleByUser(false)) } + coroutineScope.launch { + cancelEnabled = false + toggleAuth.emit(ToggleAuth.ToggleByUser(false)) } }, onClickTroubleshooting = onClickTroubleshooting @@ -266,13 +289,14 @@ fun CardWallAuthenticationDialog( AuthenticationState.HealthCardCardAccessNumberWrong -> stringResource(R.string.cdw_auth_retry_pin_can) AuthenticationState.HealthCardPin2RetriesLeft, AuthenticationState.HealthCardPin1RetryLeft -> stringResource(R.string.cdw_auth_retry_pin_can) + AuthenticationState.HealthCardBlocked -> stringResource(R.string.cdw_auth_retry_unlock_egk) else -> stringResource(R.string.cdw_auth_retry) } val retryText = when (val s = state) { AuthenticationState.IDPCommunicationFailed -> Pair( stringResource(R.string.cdw_nfc_intro_step1_header_on_error).toAnnotatedString(), - stringResource(R.string.cdw_nfc_intro_step1_info_on_error).toAnnotatedString() + stringResource(R.string.cdw_idp_error_time_and_connection).toAnnotatedString() ) AuthenticationState.IDPCommunicationInvalidCertificate -> Pair( stringResource(R.string.cdw_nfc_error_title_invalid_certificate).toAnnotatedString(), @@ -295,16 +319,16 @@ fun CardWallAuthenticationDialog( pinRetriesLeft(1) ) AuthenticationState.HealthCardBlocked -> Pair( - stringResource(R.string.cdw_nfc_intro_step2_header_on_card_blocked).toAnnotatedString(), - stringResource(R.string.cdw_nfc_intro_step2_info_on_card_blocked).toAnnotatedString() + stringResource(R.string.cdw_header_on_card_blocked).toAnnotatedString(), + stringResource(R.string.cdw_info_on_card_blocked).toAnnotatedString() ) is AuthenticationState.InsuranceIdentifierAlreadyExists -> { Pair( - stringResource(string.cdw_nfc_error_assign_title).toAnnotatedString(), + stringResource(R.string.cdw_nfc_error_assign_title).toAnnotatedString(), if (s.inActiveProfile) { - stringResource(string.cdw_nfc_error_assign_subtitle, s.insuranceIdentifier).toAnnotatedString() + stringResource(R.string.cdw_nfc_error_assign_subtitle, s.insuranceIdentifier).toAnnotatedString() } else { - stringResource(string.cdw_nfc_error_assign_other_subtitle, s.profileName).toAnnotatedString() + stringResource(R.string.cdw_nfc_error_assign_other_subtitle, s.profileName).toAnnotatedString() } ) } @@ -312,22 +336,9 @@ fun CardWallAuthenticationDialog( } if (showEnableNfcDialog) { - val header = stringResource(R.string.cdw_enable_nfc_header) - val info = stringResource(R.string.cdw_enable_nfc_info) - val enableNfcButtonText = stringResource(R.string.cdw_enable_nfc_btn_text) - val cancelText = stringResource(R.string.cancel) - - CommonAlertDialog( - header = header, - info = info, - cancelText = cancelText, - actionText = enableNfcButtonText, - onCancel = { showEnableNfcDialog = false }, - onClickAction = { - activity.startActivity(Intent("android.settings.NFC_SETTINGS").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - showEnableNfcDialog = false - } - ) + EnableNfcDialog { + showEnableNfcDialog = false + } } retryText?.let { @@ -343,6 +354,7 @@ fun CardWallAuthenticationDialog( AuthenticationState.HealthCardCardAccessNumberWrong -> onRetryCan() AuthenticationState.HealthCardPin2RetriesLeft, AuthenticationState.HealthCardPin1RetryLeft -> onRetryPin() + AuthenticationState.HealthCardBlocked -> onUnlockEgk() else -> if (viewModel.isNFCEnabled()) { coroutineScope.launch { toggleAuth.emit(ToggleAuth.ToggleByUser(true)) @@ -355,12 +367,32 @@ fun CardWallAuthenticationDialog( } @Composable -private fun ErrorDialog( +fun EnableNfcDialog(onCancel: () -> Unit) { + val context = LocalContext.current + val header = stringResource(R.string.cdw_enable_nfc_header) + val info = stringResource(R.string.cdw_enable_nfc_info) + val enableNfcButtonText = stringResource(R.string.cdw_enable_nfc_btn_text) + val cancelText = stringResource(R.string.cancel) + + CommonAlertDialog( + header = header, + info = info, + cancelText = cancelText, + actionText = enableNfcButtonText, + onCancel = onCancel, + onClickAction = { + context.handleIntent(Intent("android.settings.NFC_SETTINGS")) + } + ) +} + +@Composable +fun ErrorDialog( header: AnnotatedString, info: AnnotatedString, retryButtonText: String, onCancel: () -> Unit, - onRetry: () -> Unit, + onRetry: () -> Unit ) = CommonAlertDialog( header = header, @@ -371,11 +403,11 @@ private fun ErrorDialog( actionText = retryButtonText ) -private fun String.toAnnotatedString() = +fun String.toAnnotatedString() = buildAnnotatedString { append(this@toAnnotatedString) } @Composable -private fun pinRetriesLeft(count: Int) = +fun pinRetriesLeft(count: Int) = annotatedPluralsResource( R.plurals.cdw_nfc_intro_step2_info_on_pin_error, count, @@ -387,6 +419,7 @@ private fun pinRetriesLeft(count: Int) = private fun AuthenticationDialog( state: AuthenticationState, showTroubleshooting: Boolean, + onCancelEnabled: Boolean, onCancel: () -> Unit, onClickTroubleshooting: (() -> Unit)? = null ) { @@ -396,6 +429,7 @@ private fun AuthenticationDialog( ) { Box( Modifier + .testTag(TestTag.CardWall.Nfc.CardReadingDialog) .semantics(false) { } .fillMaxSize() .background(SolidColor(Color.Black), alpha = 0.5f) @@ -432,37 +466,16 @@ private fun AuthenticationDialog( .testTag("cdw_auth_nfc_bottom_sheet"), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - TextButton(onClick = onCancel) { - Text(stringResource(R.string.cdw_nfc_dlg_cancel).uppercase(Locale.getDefault())) - } - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .defaultMinSize(minHeight = 150.dp) - .fillMaxWidth() + TextButton( + enabled = onCancelEnabled, + onClick = onCancel ) { - when (screen) { - 0 -> SearchingCardAnimation() - 1 -> ReadingCardAnimation() - 2 -> TagLostCard() - } + Text(stringResource(R.string.cdw_nfc_dlg_cancel).uppercase(Locale.getDefault())) } + CardAnimationBox(screen) // how to hold your card - val rotatingScanCardAssistance = listOf( - Pair( - stringResource(R.string.cdw_nfc_search1_headline), - stringResource(R.string.cdw_nfc_search1_info) - ), - Pair( - stringResource(R.string.cdw_nfc_search2_headline), - stringResource(R.string.cdw_nfc_search2_info) - ), - Pair( - stringResource(R.string.cdw_nfc_search3_headline), - stringResource(R.string.cdw_nfc_search3_info) - ), - ) + val rotatingScanCardAssistance = rotatingScanCardAssistance() var info by remember { mutableStateOf(rotatingScanCardAssistance.first()) } @@ -518,13 +531,13 @@ private fun AuthenticationDialog( } else { Text( info.first, - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) Text( info.second, - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) @@ -536,19 +549,19 @@ private fun AuthenticationDialog( } @Composable -private fun Troubleshooting( +fun Troubleshooting( modifier: Modifier = Modifier, onClick: () -> Unit ) { Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { Text( - "Brauchen Sie Hilfe?", - style = MaterialTheme.typography.subtitle1, + stringResource(R.string.cdw_enter_troubleshooting_title), + style = AppTheme.typography.subtitle1, textAlign = TextAlign.Center ) Text( - "Wir haben für Sie einige Tipps zusammengestellt, um die häufigsten Probleme zu lösen.", - style = MaterialTheme.typography.body2, + stringResource(R.string.cdw_enter_troubleshooting_subtitle), + style = AppTheme.typography.body2, textAlign = TextAlign.Center ) SpacerMedium() @@ -560,17 +573,16 @@ private fun Troubleshooting( ) { Icon(Icons.Outlined.Lightbulb, null) SpacerTiny() - Text("Verbindungs-Tipps starten") + Text(stringResource(R.string.cdw_enter_troubleshooting_action)) } } } private data class Wobble(val radius: Dp, val color: Color, val delay: Int) -@ExperimentalAnimationApi +@Suppress("LongMethod") @Composable -private fun SearchingCardAnimation() { - +fun SearchingCardAnimation() { val wobbleColorL = Wobble(72.dp, AppTheme.colors.primary100.copy(alpha = 0.7f), 600) val wobbleColorM = Wobble(56.dp, AppTheme.colors.primary200.copy(alpha = 0.3f), 300) val wobbleColorS = Wobble(40.dp, AppTheme.colors.primary300.copy(alpha = 0.2f), 0) @@ -595,10 +607,11 @@ private fun SearchingCardAnimation() { DpOffset.VectorConverter, transitionSpec = { tween( - healthCardOffsetDuration, + healthCardOffsetDuration - 10, 0 ) - } + }, + label = "healthCardOffset" ) { state -> when (state) { HealthCardAnimationState.START -> DpOffset(0.dp, 0.dp) @@ -615,7 +628,8 @@ private fun SearchingCardAnimation() { 1000, 0 ) - } + }, + label = "healthCardScale" ) { state -> when (state) { HealthCardAnimationState.START -> 1.0f @@ -628,7 +642,8 @@ private fun SearchingCardAnimation() { 1300, 1500 ) - } + }, + label = "smartPhoneAlpha" ) { state -> when (state) { true -> 1.0f @@ -642,7 +657,8 @@ private fun SearchingCardAnimation() { 1300, 1500 ) - } + }, + label = "smartPhoneOffset" ) { state -> when (state) { true -> 0.dp @@ -672,7 +688,8 @@ private fun SearchingCardAnimation() { Triple( it, wobbleTransition.animateFloat( - 1.0f, 1.1f, + 1.0f, + 1.1f, animationSpec = infiniteRepeatable( animation = keyframes { durationMillis = 2500 @@ -685,7 +702,8 @@ private fun SearchingCardAnimation() { ) ), wobbleTransition.animateFloat( - 1.0f, 0.7f, + 1.0f, + 0.7f, animationSpec = infiniteRepeatable( animation = tween( durationMillis = 1000, @@ -725,7 +743,9 @@ private fun SearchingCardAnimation() { ) Image( - smartPhone, null, alpha = smartPhoneAlpha, + smartPhone, + null, + alpha = smartPhoneAlpha, modifier = Modifier .size(80.dp) .align( @@ -738,7 +758,7 @@ private fun SearchingCardAnimation() { } @Composable -private fun ReadingCardAnimation() { +fun ReadingCardAnimation() { Box { Image( painterResource(R.drawable.ic_healthcard_spinner), @@ -760,9 +780,68 @@ private fun ReadingCardAnimation() { } @Composable -private fun TagLostCard() { +fun TagLostCard() { Image( painterResource(R.drawable.ic_healthcard_tag_lost), - null, + null ) } + +private fun Analytics.trackAuth(state: AuthenticationState) { + if (trackingAllowed.value) { + when (state) { + AuthenticationState.HealthCardBlocked -> + trackAuthenticationProblem(AuthenticationProblem.CardBlocked) + AuthenticationState.HealthCardCardAccessNumberWrong -> + trackAuthenticationProblem(AuthenticationProblem.CardAccessNumberWrong) + AuthenticationState.HealthCardCommunicationInterrupted -> + trackAuthenticationProblem(AuthenticationProblem.CardCommunicationInterrupted) + AuthenticationState.HealthCardPin1RetryLeft, + AuthenticationState.HealthCardPin2RetriesLeft -> + trackAuthenticationProblem(AuthenticationProblem.CardPinWrong) + AuthenticationState.IDPCommunicationFailed -> + trackAuthenticationProblem(AuthenticationProblem.IDPCommunicationFailed) + AuthenticationState.IDPCommunicationInvalidCertificate -> + trackAuthenticationProblem(AuthenticationProblem.IDPCommunicationInvalidCertificate) + AuthenticationState.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate -> + trackAuthenticationProblem(AuthenticationProblem.IDPCommunicationInvalidOCSPOfCard) + AuthenticationState.SecureElementCryptographyFailed -> + trackAuthenticationProblem(AuthenticationProblem.SecureElementCryptographyFailed) + AuthenticationState.UserNotAuthenticated -> + trackAuthenticationProblem(AuthenticationProblem.UserNotAuthenticated) + else -> {} + } + } +} + +@Composable +fun CardAnimationBox(screen: Int) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .defaultMinSize(minHeight = 150.dp) + .fillMaxWidth() + ) { + when (screen) { + 0 -> SearchingCardAnimation() + 1 -> ReadingCardAnimation() + 2 -> TagLostCard() + } + } +} + +@Composable +fun rotatingScanCardAssistance() = listOf( + Pair( + stringResource(R.string.cdw_nfc_search1_headline), + stringResource(R.string.cdw_nfc_search1_info) + ), + Pair( + stringResource(R.string.cdw_nfc_search2_headline), + stringResource(R.string.cdw_nfc_search2_info) + ), + Pair( + stringResource(R.string.cdw_nfc_search3_headline), + stringResource(R.string.cdw_nfc_search3_info) + ) +) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallButtons.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallButtons.kt deleted file mode 100644 index 64de2ac5..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallButtons.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.ui - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonColors -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ButtonElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults - -// TODO replace with material3 - -@Composable -fun SecondaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), - shape: Shape = RoundedCornerShape(16.dp), - border: BorderStroke? = null, - colors: ButtonColors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.neutral100, - contentColor = AppTheme.colors.primary700 - ), - contentPadding: PaddingValues = PaddingValues( - horizontal = PaddingDefaults.Medium, - vertical = 12.dp - ), - content: @Composable RowScope.() -> Unit -) = - Button( - onClick = onClick, - modifier = modifier, - enabled = enabled, - interactionSource = interactionSource, - elevation = elevation, - shape = shape, - border = border, - colors = colors, - contentPadding = contentPadding, - content = content - ) - -@Composable -fun PrimaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), - shape: Shape = RoundedCornerShape(16.dp), - border: BorderStroke? = null, - colors: ButtonColors = ButtonDefaults.buttonColors(), - contentPadding: PaddingValues = PaddingValues( - horizontal = PaddingDefaults.Medium, - vertical = 12.dp - ), - content: @Composable RowScope.() -> Unit -) = - Button( - onClick = onClick, - modifier = modifier, - enabled = enabled, - interactionSource = interactionSource, - elevation = elevation, - shape = shape, - border = border, - colors = colors, - contentPadding = contentPadding, - content = content - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallComponents.kt index 3f52ebbf..580839e2 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallComponents.kt @@ -18,157 +18,116 @@ package de.gematik.ti.erp.app.cardwall.ui -import android.content.pm.PackageManager +import android.content.Intent import android.nfc.Tag import android.os.Build +import android.provider.Settings import androidx.biometric.BiometricManager -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar -import androidx.compose.material.Button import androidx.compose.material.Card import androidx.compose.material.Icon -import androidx.compose.material.IconToggleButton -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Fingerprint -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.ModelTraining import androidx.compose.material.icons.rounded.RadioButtonUnchecked -import androidx.compose.material.icons.rounded.Visibility -import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.VerbatimTtsAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.core.content.ContextCompat.startActivity +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.rememberCoroutineScope import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.cardunlock.ui.UnlockEgKScreen +import de.gematik.ti.erp.app.cardwall.domain.biometric.deviceStrongBiometricStatus +import de.gematik.ti.erp.app.cardwall.domain.biometric.hasDeviceStrongBox +import de.gematik.ti.erp.app.cardwall.domain.biometric.isDeviceSupportsBiometric import de.gematik.ti.erp.app.cardwall.ui.model.CardWallNavigation -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallSwitchNavigation -import de.gematik.ti.erp.app.cardwall.ui.model.mapCardWallNavigation import de.gematik.ti.erp.app.core.LocalActivity -import de.gematik.ti.erp.app.demo.ui.DemoBanner import de.gematik.ti.erp.app.orderhealthcard.ui.HealthCardContactOrderScreen -import de.gematik.ti.erp.app.settings.ui.FeedbackForm +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.tracking.TrackNavigationChanges +import de.gematik.ti.erp.app.analytics.TrackNavigationChanges +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.prescription.detail.ui.rememberNotSaveableNavController +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog import de.gematik.ti.erp.app.utils.compose.HintCard -import de.gematik.ti.erp.app.utils.compose.HintCardDefaults -import de.gematik.ti.erp.app.utils.compose.HintLargeImage import de.gematik.ti.erp.app.utils.compose.HintSmallImage -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton -import de.gematik.ti.erp.app.utils.compose.HintTextLearnMoreButton import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.NavigationBarMode import de.gematik.ti.erp.app.utils.compose.NavigationMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer4 -import de.gematik.ti.erp.app.utils.compose.Spacer40 -import de.gematik.ti.erp.app.utils.compose.Spacer8 -import de.gematik.ti.erp.app.utils.compose.SpacerMaxWidth +import de.gematik.ti.erp.app.utils.compose.PrimaryButton +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonLarge import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import de.gematik.ti.erp.app.utils.compose.annotatedLinkString +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource import de.gematik.ti.erp.app.utils.compose.navigationModeState -import de.gematik.ti.erp.app.utils.compose.testId -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -import java.util.Locale - -private val framePadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 24.dp) +import org.kodein.di.compose.rememberViewModel -@OptIn(ExperimentalAnimationApi::class) @Composable fun CardWallScreen( - onFinishedCardWall: () -> Unit, - canAvailable: Boolean, - viewModel: CardWallViewModel = hiltViewModel() + mainNavController: NavController, + onResumeCardWall: () -> Unit, + profileId: ProfileIdentifier ) { - val navController = rememberNavController() + val viewModel: CardWallViewModel by rememberViewModel() - val state by viewModel.state().collectAsState(viewModel.defaultState) + val navController = rememberNotSaveableNavController() - val fastTrackOn by produceState(initialValue = false) { - viewModel.fastTrackOn().collect { - value = it - } - } + val state by viewModel.state().collectAsState(viewModel.defaultState) val startDestination = when { - fastTrackOn -> CardWallNavigation.Switch.path() - canAvailable -> CardWallNavigation.PersonalIdentificationNumber.path() - state.isIntroSeenByUser -> CardWallNavigation.CardAccessNumber.path() state.hardwareRequirementsFulfilled -> CardWallNavigation.Intro.path() else -> CardWallNavigation.MissingCapabilities.path() } val context = LocalContext.current - val biometricMode = remember { - val biometricManager = BiometricManager.from(context) - biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) - } + val biometricMode = remember { deviceStrongBiometricStatus(context) } val navigationMode by navController.navigationModeState( startDestination = startDestination, @@ -186,6 +145,7 @@ fun CardWallScreen( popUpTo(CardWallNavigation.CardAccessNumber.path()) { inclusive = true } } } + val onRetryPin = { navController.navigate(CardWallNavigation.PersonalIdentificationNumber.path()) { popUpTo(CardWallNavigation.PersonalIdentificationNumber.path()) { @@ -193,111 +153,105 @@ fun CardWallScreen( } } } - val onBack: () -> Unit = { navController.popBackStack() } + + val onUnlockEgk = { + navController.navigate(CardWallNavigation.UnlockEgk.path()) { + popUpTo(CardWallNavigation.UnlockEgk.path()) { + inclusive = true + } + } + } + + val onBack: () -> Unit = { + if (navController.currentDestination?.route == startDestination) { + mainNavController.popBackStack() + } else { + navController.popBackStack() + } + } TrackNavigationChanges(navController) + var cardAccessNumber by remember { mutableStateOf("") } + var personalIdentificationNumber by remember { mutableStateOf("") } + var altPairingInitialState: AltPairingProvider.AuthResult? by remember { mutableStateOf(null) } + + val authenticationData by derivedStateOf { + (altPairingInitialState as? AltPairingProvider.AuthResult.Initialized)?.let { + CardWallAuthenticationData.AltPairingWithHealthCard( + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber, + initialPairingData = it + ) + } ?: CardWallAuthenticationData.HealthCard( + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber + ) + } + NavHost( navController, startDestination = startDestination ) { - composable(CardWallNavigation.Intro.route) { - Box( - modifier = Modifier - .background(AppTheme.colors.primary100) - .fillMaxSize() - ) { - NavigationAnimation(mode = navigationMode) { - val color = AppTheme.colors.primary100 - CardWallIntroScaffold( - onNext = { navController.navigate(CardWallNavigation.CardAccessNumber.path()) }, - topColor = color, - enableNext = true, - navigationMode = NavigationBarMode.Back, - ) { - AddCardContent( - topColor = color, - onClickLearnMore = { navController.navigate(CardWallNavigation.OrderHealthCard.path()) } - ) - } - } + NavigationAnimation(mode = navigationMode) { + CardWallIntroScaffold( + onNext = { navController.navigate(CardWallNavigation.CardAccessNumber.path()) }, + actions = { + TextButton(onClick = { onResumeCardWall() }) { + Text(stringResource(R.string.cancel)) + } + }, + onClickAlternateAuthentication = { navController.navigate(CardWallNavigation.ExternalAuthenticator.path()) }, + onClickOrderNow = { navController.navigate(CardWallNavigation.OrderHealthCard.path()) } + ) } } composable(CardWallNavigation.OrderHealthCard.route) { - HealthCardContactOrderScreen(onBack = { navController.popBackStack() }) + HealthCardContactOrderScreen(onBack = onBack) } composable(CardWallNavigation.MissingCapabilities.route) { CardWallMissingCapabilities() } - composable(CardWallNavigation.Switch.route) { - var navSelection by rememberSaveable { mutableStateOf(CardWallSwitchNavigation.NO_ROUTE) } - CardWallIntroScaffold( - content = { - CardWallAuthenticationChooser( - navSelection = navSelection, - onSelected = { onSelection -> navSelection = onSelection }, - hasNfc = state.hardwareRequirementsFulfilled, - ) - }, - onNext = { - if (navSelection == CardWallSwitchNavigation.INSURANCE_APP) { - navController.navigate(CardWallNavigation.ExternalAuthenticator.path()) - } else - navController.navigate(mapCardWallNavigation(navSelection).path()) - }, - topColor = MaterialTheme.colors.background, - enableNext = navSelection != CardWallSwitchNavigation.NO_ROUTE, - navigationMode = NavigationBarMode.Close - ) - } - - composable(CardWallNavigation.CardAccessNumber.route) { - viewModel.onIntroSeenByUser() - + composable( + CardWallNavigation.CardAccessNumber.route, + CardWallNavigation.CardAccessNumber.arguments + ) { NavigationAnimation(mode = navigationMode) { CardAccessNumber( onClickLearnMore = { navController.navigate(CardWallNavigation.OrderHealthCard.path()) }, - navMode = navigationMode, - can = state.cardAccessNumber, - onCanChange = { viewModel.onCardAccessNumberChange(it) }, - demoMode = state.demoMode, - next = { navController.navigate(CardWallNavigation.PersonalIdentificationNumber.path()) } + can = cardAccessNumber, + screenTitle = stringResource(R.string.cdw_top_bar_title), + onCanChange = { cardAccessNumber = it }, + onCancel = { onResumeCardWall() }, + onNext = { + navController.navigate(CardWallNavigation.PersonalIdentificationNumber.path()) + }, + nextText = stringResource(R.string.unlock_egk_next) ) } } composable(CardWallNavigation.PersonalIdentificationNumber.route) { NavigationAnimation(mode = navigationMode) { - PersonalIdentificationNumber( - navigationMode, - state.personalIdentificationNumber, - demoMode = state.demoMode, - onPinChange = { viewModel.onPersonalIdentificationChange(it) } + PersonalIdentificationNumberScreen( + navMode = navigationMode, + secret = personalIdentificationNumber, + onPinChange = { personalIdentificationNumber = it }, + onCancel = { onResumeCardWall() }, + onBack = { onBack() }, + onClickNoPinReceived = { navController.navigate(CardWallNavigation.OrderHealthCard.path()) } ) { - val deviceSupportsBiometric = when (biometricMode) { - BiometricManager.BIOMETRIC_SUCCESS, - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> - true - else -> - false - } - val deviceSupportsStrongbox = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) - } else { - false - } + val deviceSupportsBiometric = isDeviceSupportsBiometric(biometricMode) + val deviceSupportsStrongbox = hasDeviceStrongBox(context) if (deviceSupportsBiometric && - deviceSupportsStrongbox && - state.selectedAuthenticationMethod == CardWallData.AuthenticationMethod.None + deviceSupportsStrongbox ) { navController.navigate(CardWallNavigation.AuthenticationSelection.path()) } else { - viewModel.onSelectAuthenticationMethod(CardWallData.AuthenticationMethod.HealthCard) navController.navigate(CardWallNavigation.Authentication.path()) } } @@ -307,10 +261,13 @@ fun CardWallScreen( composable(CardWallNavigation.AuthenticationSelection.route) { NavigationAnimation(mode = navigationMode) { AuthenticationSelection( - state.demoMode, - biometricMode = biometricMode, - selectedAuthMode = state.selectedAuthenticationMethod, - onSelectAuthMode = { viewModel.onSelectAuthenticationMethod(it) } + onSelectAlternativeOption = { + navController.navigate( + CardWallNavigation.AlternativeOption.path() + ) + }, + onCancel = { onResumeCardWall() }, + onBack = onBack ) { navController.navigate( CardWallNavigation.Authentication.path() @@ -319,15 +276,54 @@ fun CardWallScreen( } } + composable(CardWallNavigation.AlternativeOption.route) { + val altPairing = rememberAltPairing() + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + (altPairingInitialState as? AltPairingProvider.AuthResult.Initialized)?.let { + altPairing.cleanup(it.aliasOfSecureElementEntry) + altPairingInitialState = null + } + } + + NavigationAnimation(mode = navigationMode) { + AlternativeOptionInfoScreen( + onCancel = onBack, + onAccept = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + scope.launch { + when (val r = altPairing.initializeAndPrompt()) { + is AltPairingProvider.AuthResult.Initialized -> { + altPairingInitialState = r + navController.navigate( + CardWallNavigation.Authentication.path(), + navOptions = navOptions { + popUpTo(CardWallNavigation.AuthenticationSelection.route) + } + ) + } + else -> { + onBack() + } + } + } + } else { + onBack() + } + } + ) + } + } + composable(CardWallNavigation.Authentication.route) { NavigationAnimation(mode = navigationMode) { - Authentication( - viewModel, state.demoMode, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + CardWallNfcInstructionScreen( + viewModel = viewModel, + profileId = profileId, + authenticationData = authenticationData, onNext = { - onFinishedCardWall() + onResumeCardWall() }, onRetryCan = { navController.navigate(CardWallNavigation.CardAccessNumber.path()) { @@ -341,31 +337,38 @@ fun CardWallScreen( } } }, + onUnlockEgk = onUnlockEgk, onClickTroubleshooting = { navController.navigate(CardWallNavigation.TroubleshootingPageA.path()) - } + }, + onBack = onBack ) } } composable(CardWallNavigation.ExternalAuthenticator.route) { NavigationAnimation(mode = navigationMode) { - ExternalAuthenticatorListScreen(navController) + ExternalAuthenticatorListScreen( + profileId = profileId, + onNext = onResumeCardWall, + onCancel = { onResumeCardWall() }, + onBack = onBack + ) } } composable(CardWallNavigation.TroubleshootingPageA.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingPageA( + profileId = profileId, viewModel = viewModel, - onFinal = onFinishedCardWall, + onFinal = onResumeCardWall, onBack = onBack, onNext = { navController.navigate(CardWallNavigation.TroubleshootingPageB.path()) }, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + authenticationData = authenticationData, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) } } @@ -373,15 +376,15 @@ fun CardWallScreen( composable(CardWallNavigation.TroubleshootingPageB.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingPageB( + profileId = profileId, viewModel = viewModel, - onFinal = onFinishedCardWall, + onFinal = onResumeCardWall, onBack = onBack, onNext = { navController.navigate(CardWallNavigation.TroubleshootingPageC.path()) }, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + authenticationData = authenticationData, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) } } @@ -389,15 +392,15 @@ fun CardWallScreen( composable(CardWallNavigation.TroubleshootingPageC.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingPageC( + profileId = profileId, viewModel = viewModel, - onFinal = onFinishedCardWall, + onFinal = onResumeCardWall, onBack = onBack, onNext = { navController.navigate(CardWallNavigation.TroubleshootingNoSuccessPage.path()) }, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + authenticationData = authenticationData, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) } } @@ -405,402 +408,218 @@ fun CardWallScreen( composable(CardWallNavigation.TroubleshootingNoSuccessPage.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingNoSuccessPage( - onClickContactUs = { navController.navigate(CardWallNavigation.TroubleshootingContactUs.path()) }, onBack = onBack, - onNext = onFinishedCardWall + onNext = onResumeCardWall ) } } - composable(CardWallNavigation.TroubleshootingContactUs.route) { + composable(CardWallNavigation.UnlockEgk.route) { NavigationAnimation(mode = navigationMode) { - FeedbackForm(navController) + UnlockEgKScreen( + unlockMethod = UnlockMethod.ResetRetryCounter, + navController = mainNavController, + onClickLearnMore = { navController.navigate(CardWallNavigation.OrderHealthCard.path()) } + ) } } } } @Composable -private fun CardAccessNumber( - onClickLearnMore: () -> Unit, +fun PersonalIdentificationNumberScreen( navMode: NavigationMode, - can: String, - demoMode: Boolean, - onCanChange: (String) -> Unit, - next: () -> Unit + secret: String, + onPinChange: (String) -> Unit, + onCancel: () -> Unit, + onClickNoPinReceived: () -> Unit, + onBack: () -> Unit, + next: (String) -> Unit ) { + CardWallSecretScreen( + navMode = navMode, + secret = secret, + secretRange = 6..8, + onSecretChange = onPinChange, + screenTitle = stringResource(R.string.cdw_top_bar_title), + next = next, + nextText = stringResource(R.string.cdw_forward), + onCancel = onCancel, + onBack = onBack, + onClickNoPinReceived = onClickNoPinReceived + ) +} - CardWallScaffold( - modifier = Modifier.testTag("cardWall/cardAccessNumber"), - backMode = when (navMode) { - NavigationMode.Forward, - NavigationMode.Back, - NavigationMode.Closed -> NavigationBarMode.Back - NavigationMode.Open -> NavigationBarMode.Close - }, - title = stringResource(R.string.cdw_top_bar_title), - nextEnabled = can.length == 6, - onNext = { next() }, - demoMode = demoMode - ) { - Text( - stringResource(R.string.cdw_can_headline), - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(PaddingDefaults.Medium) - ) - - Image( - painterResource(R.drawable.card_wall_card_can), - null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - Column { - val textValue = TextFieldValue( - annotatedString = buildAnnotatedString { - pushTtsAnnotation(VerbatimTtsAnnotation(can)) - append(can) - pop() - }, - selection = TextRange(can.length) - ) - - var isFocussed by remember { mutableStateOf(false) } - val canRegex = """^\d{0,6}$""".toRegex() - - BasicTextField( - value = textValue, - onValueChange = { - if (it.text.matches(canRegex)) { - onCanChange(it.text) - } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Next - ), - keyboardActions = KeyboardActions( - onNext = { - if (can.length == 6) { - next() - } - } - ), - singleLine = true, - modifier = Modifier - .testTag("cardWall/cardAccessNumberInputField") - .fillMaxWidth() - .padding(start = 24.dp, bottom = 8.dp, end = 24.dp) - .onFocusChanged { - isFocussed = it.isFocused - } - .testId("cdw_edt_can_input") - ) { - Box(modifier = Modifier.fillMaxWidth()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.align(Alignment.Center) - ) { - val shape = RoundedCornerShape(8.dp) - val backgroundColor = AppTheme.colors.neutral200 - val borderModifier = Modifier.border( - BorderStroke(1.dp, color = AppTheme.colors.primary700), - shape - ) - - repeat(6) { - Box( - modifier = Modifier - .size(40.dp, 48.dp) - .shadow(1.dp, shape) - .then(if (can.length == it && isFocussed) borderModifier else Modifier) - .background( - color = backgroundColor, shape, - ) - .graphicsLayer { - clip = false - } - ) { - Text( - text = can.getOrNull(it)?.toString() ?: " ", - style = MaterialTheme.typography.h6, - modifier = Modifier - .align(Alignment.Center) - .clearAndSetSemantics { } - ) - } - } - } - } - } - - Text( - stringResource(R.string.cdw_can_caption), - style = AppTheme.typography.captionl, - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) - ) - Spacer40() - if (demoMode) { - DemoInputHint( - stringResource(R.string.cdw_can_demo_info), - Modifier - .padding(horizontal = PaddingDefaults.Medium) - .fillMaxWidth() - ) - } else { - HintCard( - image = { - HintLargeImage( - painterResource(R.drawable.pharmacist_2), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.cdw_can_info_hint_header)) }, - body = { Text(stringResource(R.string.cdw_can_info_hint_info)) }, - action = { - HintTextActionButton(text = stringResource(R.string.learn_more_btn)) { - onClickLearnMore() - } - }, - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) - ) - } - SpacerMedium() - } - } +private enum class AuthenticationMethod { + None, + Alternative, // e.g. biometrics + HealthCard } -@OptIn(ExperimentalComposeUiApi::class) +@Suppress("ComplexMethod") @Composable -private fun PersonalIdentificationNumber( - navMode: NavigationMode, - pin: String, - demoMode: Boolean, - onPinChange: (String) -> Unit, - next: (String) -> Unit +private fun AuthenticationSelection( + onSelectAlternativeOption: () -> Unit, + onCancel: () -> Unit, + onBack: () -> Unit, + onNext: () -> Unit ) { + var selectedAuthMode by remember { mutableStateOf(AuthenticationMethod.None) } - CardWallScaffold( - modifier = Modifier.testTag("cardWall/personalIdentificationNumber"), - backMode = when (navMode) { - NavigationMode.Forward, - NavigationMode.Back, - NavigationMode.Closed -> NavigationBarMode.Back - NavigationMode.Open -> NavigationBarMode.Close - }, - title = stringResource(R.string.cdw_top_bar_title), - nextEnabled = pin.length in 6..8, - onNext = { next(pin) }, - demoMode = demoMode - ) { - - Text( - stringResource(R.string.cdw_pin_title), - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(PaddingDefaults.Medium) - ) + val context = LocalContext.current + val biometricMode by produceState(initialValue = BiometricManager.BIOMETRIC_STATUS_UNKNOWN) { + value = deviceStrongBiometricStatus(context) + } + var showEnrollBiometricDialog by remember { mutableStateOf(false) } + var showWillBeLoggedOfHint by remember { mutableStateOf(false) } - Column { - val textValue = TextFieldValue( - annotatedString = buildAnnotatedString { - pushTtsAnnotation(VerbatimTtsAnnotation(pin)) - append(pin) - pop() - }, - selection = TextRange(pin.length) - ) + val enrollBiometricIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent(Settings.ACTION_BIOMETRIC_ENROLL) + } else { + Intent(Settings.ACTION_APPLICATION_SETTINGS) + } - var isFocussed by remember { mutableStateOf(false) } - val pinRegex = """^\d{0,8}$""".toRegex() + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier + .testTag("cardWall/authenticationSelection"), + title = stringResource(R.string.cdw_top_bar_title), + nextEnabled = selectedAuthMode != AuthenticationMethod.None, + onNext = onNext, + onBack = onBack, + listState = lazyListState, + nextText = stringResource(R.string.cdw_next), + actions = @Composable { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) { + LazyColumn( + state = lazyListState + ) { + item { + Text( + stringResource(R.string.cdw_selection_title), + style = AppTheme.typography.h5, + modifier = Modifier.padding(PaddingDefaults.Medium) + ) + SpacerXXLarge() - BasicTextField( - value = textValue, - onValueChange = { - if (it.text.matches(pinRegex)) { - onPinChange(it.text) - } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.NumberPassword, - imeAction = ImeAction.Next - ), - keyboardActions = KeyboardActions( - onNext = { - if (pin.length in 6..8) { - next(pin) - } - } - ), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .testTag("cardWall/personalIdentificationNumberInputField") - .padding( - start = PaddingDefaults.Medium, - bottom = PaddingDefaults.Small, - end = PaddingDefaults.Medium - ) - .onFocusChanged { - isFocussed = it.isFocused + SelectableCard( + modifier = Modifier.testTag(TestTag.CardWall.StoreCredentials.Save), + selected = selectedAuthMode == AuthenticationMethod.Alternative, + startIcon = Icons.Rounded.Check, + text = stringResource(R.string.cdw_selection_save) + ) { + showWillBeLoggedOfHint = false + if (biometricMode == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED) { + showEnrollBiometricDialog = true + } else { + selectedAuthMode = AuthenticationMethod.Alternative + onSelectAlternativeOption() } - .testId("cdw_edt_pin_input") - ) { - Column(modifier = Modifier.fillMaxWidth()) { - val shape = RoundedCornerShape(8.dp) - val borderModifier = Modifier.border( - BorderStroke(1.dp, color = AppTheme.colors.primary700), - shape - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .heightIn(min = 48.dp) - .shadow(1.dp, shape) - .then(if (isFocussed && pin.length < 8) borderModifier else Modifier) - .background( - color = AppTheme.colors.neutral200, - shape = shape - ) - ) { - var pinVisible by remember { mutableStateOf(false) } - val transformedPin = if (pinVisible) { - pin - } else { - pin.asIterable().joinToString(separator = "") { "\u2B24" } - } - - Spacer(modifier = Modifier.size(48.dp)) + } - Text( - text = transformedPin, - style = MaterialTheme.typography.h6.copy(letterSpacing = 0.7.em), - textAlign = TextAlign.Center, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - .clearAndSetSemantics { } - ) + SpacerMedium() - IconToggleButton( - checked = pinVisible, - onCheckedChange = { pinVisible = it }, - modifier = Modifier.align(Alignment.CenterVertically) - ) { - when (pinVisible) { - true -> Icon( - Icons.Rounded.Visibility, - null, - tint = AppTheme.colors.neutral600 - ) - false -> Icon( - Icons.Rounded.VisibilityOff, - null, - tint = AppTheme.colors.neutral600 - ) - } - } - } - Text( - stringResource(R.string.cdw_pin_caption), - style = AppTheme.typography.captionl, - modifier = Modifier.fillMaxWidth() + SelectableCard( + modifier = Modifier + .testTag(TestTag.CardWall.StoreCredentials.DontSave), + selected = selectedAuthMode == AuthenticationMethod.HealthCard, + startIcon = Icons.Rounded.Close, + text = stringResource(R.string.cdw_selection_save_not) + ) { + selectedAuthMode = AuthenticationMethod.HealthCard + showWillBeLoggedOfHint = true + } + SpacerXXLarge() + if (showWillBeLoggedOfHint) { + SpacerMedium() + HintCard( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + image = { + HintSmallImage(painterResource(R.drawable.information), null, it) + }, + title = { Text(stringResource(R.string.cdw_selection_hint_title)) }, + body = { Text(stringResource(R.string.cdw_selection_hint_info_)) } ) } } - Spacer40() - if (demoMode) { - DemoInputHint( - stringResource(R.string.cdw_pin_demo_info), - Modifier - .padding(horizontal = PaddingDefaults.Medium) - .fillMaxWidth() - ) - } else { - HintCard( - image = { - HintSmallImage( - painterResource(R.drawable.pharmacist_circle), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.cdw_pin_info_hint_header)) }, - body = { Text(stringResource(R.string.cdw_pin_info_hint_info)) }, - action = { - HintTextLearnMoreButton() - }, - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) - ) - } - SpacerMedium() + } + } + + if (showEnrollBiometricDialog) { + CommonAlertDialog( + icon = Icons.Rounded.Fingerprint, + header = stringResource(R.string.enroll_biometric_dialog_header), + info = stringResource(R.string.enroll_biometric_dialog_info), + cancelText = stringResource(R.string.enroll_biometric_dialog_cancel), + actionText = stringResource(R.string.enroll_biometric_dialog_settings), + onCancel = { showEnrollBiometricDialog = false } + ) { + startActivity(context, enrollBiometricIntent, null) } } } -@OptIn(ExperimentalComposeUiApi::class) @Composable -private fun AuthenticationSelection( - demoMode: Boolean, - biometricMode: Int, - selectedAuthMode: CardWallData.AuthenticationMethod, - onSelectAuthMode: (CardWallData.AuthenticationMethod) -> Unit, - next: () -> Unit -) { - CardWallScaffold( - modifier = Modifier.testTag("cardWall/authenticationSelection"), - title = stringResource(R.string.cdw_top_bar_title), - nextEnabled = selectedAuthMode != CardWallData.AuthenticationMethod.None, - onNext = { - next() - }, - demoMode = demoMode +fun AlternativeOptionInfoScreen(onCancel: () -> Unit, onAccept: () -> Unit) { + CardWallInfoScaffold( + topColor = MaterialTheme.colors.background, + onNext = onAccept, + onCancel = onCancel ) { - - Text( - stringResource(R.string.cdw_save_access_title), - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(16.dp) - ) - - val biometricText = when (biometricMode) { - BiometricManager.BIOMETRIC_SUCCESS -> Pair( - stringResource(R.string.cdw_save_with_biometry_title), - stringResource(R.string.cdw_save_with_biometry_info) + Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { + Text( + stringResource(R.string.cdw_info_header), + style = AppTheme.typography.h5 + ) + SpacerSmall() + Text( + stringResource(R.string.cdw_info_first), + style = AppTheme.typography.body1 ) - else -> Pair( - stringResource(R.string.cdw_biometric_not_possible_title), - stringResource(R.string.cdw_biometric_not_possible_info) + SpacerSmall() + Text( + stringResource(R.string.cdw_info_second), + style = AppTheme.typography.body1 + ) + SpacerSmall() + Text( + stringResource(R.string.cdw_info_third), + style = AppTheme.typography.body1 ) } + } +} - if (biometricMode == BiometricManager.BIOMETRIC_SUCCESS) { - SelectableCard( - modifier = Modifier.testId("cdw_btn_option_alternative"), - enabled = true, - selected = selectedAuthMode == CardWallData.AuthenticationMethod.Alternative, - image = { CardImageVector(image = Icons.Rounded.Fingerprint, enabled = true) }, - biometricText.first, - biometricText.second +@Composable +fun AlternativeInfoBottomBar(onNext: () -> Unit) { + Surface( + color = MaterialTheme.colors.surface, + elevation = 4.dp + ) { + Column( + Modifier + .navigationBarsPadding() + .fillMaxWidth() + ) { + PrimaryButton( + onClick = onNext, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTag.CardWall.SecurityAcceptance.AcceptButton) + .padding( + horizontal = 76.dp, + vertical = PaddingDefaults.Small + PaddingDefaults.Tiny + ) ) { - onSelectAuthMode(CardWallData.AuthenticationMethod.Alternative) + Text( + stringResource(R.string.cdw_info_accept) + ) } - } else { - BiometricInfoCard(biometricText.first, biometricText.second) } - - SelectableCard( - modifier = Modifier - .testId("cdw_btn_option_healthcard") - .testTag("cardWall/authenticationSelection/healthCard"), - enabled = true, - selected = selectedAuthMode == CardWallData.AuthenticationMethod.HealthCard, - image = { CardImageVector(image = Icons.Rounded.Lock, enabled = true) }, - stringResource(R.string.cdw_not_save_with_biometry_title), - stringResource(R.string.cdw_not_save_with_biometry_info) - ) { - onSelectAuthMode(CardWallData.AuthenticationMethod.HealthCard) - } - Spacer8() } } @@ -809,23 +628,9 @@ private fun AuthenticationSelection( private fun PreviewSelectableCard() { AppTheme { SelectableCard( - image = { CardImageVector(image = Icons.Filled.Lock, enabled = true) }, - header = "That is a header", info = "Info here" - ) - } -} - -@Composable -fun CardImageVector(image: ImageVector, enabled: Boolean) { - Surface( - modifier = Modifier.size(64.dp), - shape = CircleShape, - color = if (enabled) AppTheme.colors.primary100 else AppTheme.colors.neutral200 - ) { - Icon( - image, null, - tint = if (enabled) AppTheme.colors.primary600 else AppTheme.colors.neutral400, - modifier = Modifier.padding(16.dp) + startIcon = Icons.Rounded.Check, + text = "Info here", + selected = true ) } } @@ -833,14 +638,11 @@ fun CardImageVector(image: ImageVector, enabled: Boolean) { @Composable fun SelectableCard( modifier: Modifier = Modifier, - enabled: Boolean = true, selected: Boolean = false, - image: @Composable () -> (Unit), - header: String, - info: String, - onCardSelected: () -> Unit = {}, + startIcon: ImageVector, + text: String, + onCardSelected: () -> Unit = {} ) { - val checkIcon = if (selected) { Icons.Rounded.CheckCircle } else { @@ -853,170 +655,51 @@ fun SelectableCard( AppTheme.colors.neutral400 } - val elevation = if (selected) { - 8.dp + val cardBorderStroke = if (selected) { + BorderStroke(2.dp, AppTheme.colors.primary600) } else { - 2.dp - } - - var cardBackGroundColor = AppTheme.colors.neutral000 - var textcolor = MaterialTheme.colors.onBackground - - if (!enabled) { - cardBackGroundColor = AppTheme.colors.neutral050 - textcolor = AppTheme.colors.neutral600 + BorderStroke(1.dp, AppTheme.colors.neutral300) } Card( - border = BorderStroke(0.5.dp, AppTheme.colors.neutral300), - backgroundColor = cardBackGroundColor, - modifier = Modifier - .padding(vertical = PaddingDefaults.Small, horizontal = PaddingDefaults.Medium) + border = cardBorderStroke, + backgroundColor = AppTheme.colors.neutral000, + modifier = modifier + .padding(horizontal = PaddingDefaults.Medium) .fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - elevation = elevation, + shape = RoundedCornerShape(8.dp) ) { - Column( - modifier = modifier + Row( + modifier = Modifier + .fillMaxWidth() .clickable( - enabled = enabled, + enabled = true, onClick = onCardSelected ) + .padding(PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top - ) { - Box(Modifier.weight(1f)) { - SpacerMaxWidth() - } - Box( - Modifier - .weight(1f) - .padding(vertical = PaddingDefaults.Medium), - contentAlignment = Alignment.Center - ) { - image.invoke() - } - Box(Modifier.weight(1f)) { - Icon( - checkIcon, - null, - tint = checkIconTint, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 16.dp) - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 4.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.Center - ) { - Text(header, style = MaterialTheme.typography.subtitle1, color = textcolor) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.Center - ) { - Text( - info, - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center, - color = textcolor - ) - } - } - } -} - -@Composable -private fun BiometricInfoCard( - header: String, - info: String, -) { - Card( - border = BorderStroke(0.5.dp, AppTheme.colors.neutral300), - backgroundColor = AppTheme.colors.neutral050, - modifier = Modifier - .padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.Small) - .fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - elevation = 2.dp - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row { - Box( - Modifier - .weight(1f) - .padding(bottom = 16.dp) - ) { - Surface( - modifier = Modifier - .size(64.dp) - .align(Alignment.TopCenter), - shape = CircleShape, - color = AppTheme.colors.neutral200 - ) { - Icon( - Icons.Rounded.Fingerprint, - null, - tint = AppTheme.colors.neutral400, - modifier = Modifier.padding(16.dp) - ) - } - } - } - - Text( - header, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - color = AppTheme.colors.neutral600 + Icon( + startIcon, + null, + tint = AppTheme.colors.primary600 ) - Spacer4() + Text( - info, - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center, - color = AppTheme.colors.neutral600 + text, + style = AppTheme.typography.subtitle1, + modifier = modifier + .weight(1f) + .padding(start = PaddingDefaults.Medium) ) - } - } -} -@Composable -private fun DemoInputHint(text: String, modifier: Modifier) { - HintCard( - modifier = modifier, - properties = HintCardDefaults.properties( - backgroundColor = AppTheme.colors.primary100, - contentColor = AppTheme.colors.primary900, - border = BorderStroke(0.5.dp, AppTheme.colors.primary300) - ), - image = { Icon( - Icons.Rounded.ModelTraining, null, - modifier = Modifier - .padding(it) - .requiredSize(40.dp) + checkIcon, + null, + tint = checkIconTint ) - }, - title = null, - body = { Text(text) } - ) + } + } } sealed class ToggleAuth { @@ -1025,137 +708,116 @@ sealed class ToggleAuth { } @Composable -private fun Authentication( - viewModel: CardWallViewModel, - demoMode: Boolean, - authenticationMethod: CardWallData.AuthenticationMethod, - cardAccessNumber: String, - personalIdentificationNumber: String, - onNext: () -> Unit, - onRetryCan: () -> Unit, - onRetryPin: () -> Unit, - onClickTroubleshooting: () -> Unit -) { - var showProgressIndicator by remember { mutableStateOf(false) } - val dialogState = rememberCardWallAuthenticationDialogState() - - CardWallAuthenticationDialog( - dialogState = dialogState, - viewModel = viewModel, - authenticationMethod = authenticationMethod, - cardAccessNumber = cardAccessNumber, - personalIdentificationNumber = personalIdentificationNumber, - troubleShootingEnabled = true, - allowUserCancellation = !demoMode, - onFinal = onNext, - onRetryCan = onRetryCan, - onRetryPin = onRetryPin, - onClickTroubleshooting = onClickTroubleshooting, - onStateChange = { - showProgressIndicator = it.isInProgress() - } - ) - - val coroutineScope = rememberCoroutineScope() - - CardWallScaffold( - modifier = Modifier.testTag("cardWall/authentication"), - title = stringResource(R.string.cdw_top_bar_title), - onNext = if (demoMode) { - { coroutineScope.launch { dialogState.show() } } - } else null, - demoMode = demoMode - ) { - Box { - Column(modifier = Modifier.padding(framePadding)) { - Text( - stringResource(R.string.cdw_nfc_intro_headline), - style = MaterialTheme.typography.h6, - modifier = Modifier.testTag("cdw_txt_auth_title") - ) - Spacer8() - Text( - stringResource(R.string.cdw_nfc_intro_body), - style = MaterialTheme.typography.body1 - ) - Spacer16() - Surface(modifier = Modifier.fillMaxSize()) { - InstructionVideo() - } - } - if (showProgressIndicator) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - } - } -} - -@Composable -fun CardWallScaffold( +fun CardHandlingScaffold( modifier: Modifier = Modifier, title: String, onBack: (() -> Unit)? = null, onNext: (() -> Unit)?, nextEnabled: Boolean = true, - nextText: String = stringResource(R.string.cdw_next), - demoMode: Boolean, + nextText: String, backMode: NavigationBarMode = NavigationBarMode.Back, - content: @Composable ColumnScope.() -> Unit + actions: @Composable RowScope.() -> Unit = {}, + listState: LazyListState, + content: @Composable (PaddingValues) -> Unit ) { val activity = LocalActivity.current - - Scaffold( - modifier = modifier, - topBar = { - NavigationTopAppBar( - navigationMode = backMode, - title = title, - onBack = if (onBack == null) { - { activity.onBackPressed() } - } else { - onBack - } - ) + AnimatedElevationScaffold( + topBarTitle = title, + navigationMode = backMode, + actions = actions, + onBack = if (onBack == null) { + { activity.onBackPressed() } + } else { + onBack }, bottomBar = { if (onNext != null) { - CardWallBottomBar(onNext, nextEnabled, nextText) + CardWallBottomBar(onNext = onNext, nextEnabled = nextEnabled, nextText = nextText) } - } + }, + modifier = modifier, + topBarColor = MaterialTheme.colors.surface, + listState = listState, + content = content + ) +} + +@Composable +fun CardWallBottomBar( + onNext: () -> Unit, + nextEnabled: Boolean, + nextText: String = stringResource(R.string.cdw_next) +) { + Surface( + color = MaterialTheme.colors.surface, + elevation = 4.dp ) { - Column { - if (demoMode) { - DemoBanner {} - } - Column( + Column( + Modifier + .imePadding() + .navigationBarsPadding() + .fillMaxWidth() + ) { + PrimaryButton( + onClick = onNext, + enabled = nextEnabled, modifier = Modifier - .padding(it) - .verticalScroll(rememberScrollState()) + .testTag(TestTag.CardWall.ContinueButton) + .padding( + horizontal = PaddingDefaults.Medium, + vertical = PaddingDefaults.ShortMedium + ) + .align(Alignment.End) ) { - content() + Text( + nextText + ) } } } } @Composable -fun CardWallBottomBar( - onNext: () -> Unit, - nextEnabled: Boolean, - nextText: String = stringResource(R.string.cdw_next) +fun CardWallIntroBottomBar( + onClickAlternateAuthentication: () -> Unit, + onNext: () -> Unit ) { - BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = onNext, - enabled = nextEnabled, - modifier = Modifier.testTag("cardWall/next") + Surface( + color = MaterialTheme.colors.surface, + elevation = 4.dp + ) { + Column( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .padding(PaddingDefaults.ShortMedium), + horizontalAlignment = Alignment.CenterHorizontally ) { + PrimaryButtonLarge( + onClick = onNext, + modifier = Modifier + .testTag(TestTag.CardWall.ContinueButton) + ) { + Text( + stringResource(R.string.cdw_next) + ) + } + SpacerMedium() Text( - nextText.uppercase(Locale.getDefault()), - modifier = Modifier.testId("cdw_btn_next") + annotatedStringResource( + R.string.cdw_intro_alternate_auth_info, + annotatedLinkString( + stringResource(R.string.cdw_intro_alternate_auth_info_link), + stringResource(R.string.cdw_intro_alternate_auth_info_link) + ) + ), + style = AppTheme.typography.body2l.merge(TextStyle(textAlign = TextAlign.Center)), + modifier = Modifier.clickable( + onClick = onClickAlternateAuthentication, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) ) } - Spacer8() } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallInstructionVideo.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallInstructionVideo.kt deleted file mode 100644 index d38f8413..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallInstructionVideo.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.ui - -import android.media.MediaPlayer -import android.view.SurfaceHolder -import android.view.SurfaceView -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon -import androidx.compose.material.IconToggleButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ClosedCaption -import androidx.compose.material.icons.rounded.ClosedCaptionDisabled -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.view.updateLayoutParams -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.utils.compose.Spacer16 - -private val captionLanguageMapping = listOf( - "de" to R.raw.subtitles_cdw_instruction_de, - "en" to R.raw.subtitles_cdw_instruction_en, - "tr" to R.raw.subtitles_cdw_instruction_tr -) - -@Composable -fun InstructionVideo() { - val context = LocalContext.current - val config = LocalConfiguration.current - - var caption by remember { mutableStateOf("") } - var aspect by remember { mutableStateOf(1.0f) } - val player = remember { - MediaPlayer().apply { - setOnTimedTextListener { _, text -> - caption = text?.text?.trim() ?: "" - } - - setDataSource(context.resources.openRawResourceFd(R.raw.animation_cdw_instruction)) - setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) - - setOnVideoSizeChangedListener { _, width, height -> - aspect = (width.toFloat() / height).takeIf { !it.isNaN() } ?: 1f - } - - isLooping = true - - captionLanguageMapping.forEach { (_, id) -> - val fd = context.resources.openRawResourceFd(id) - addTimedTextSource(fd.fileDescriptor, fd.startOffset, fd.length, "application/x-subrip") - } - } - } - - LaunchedEffect(player, config) { - // first entry is preferred language - val displayLang = config.locales[0].language - val captionSuffix = when { - displayLang.startsWith("en") -> "en" - displayLang.startsWith("de") -> "de" - displayLang.startsWith("tr") -> "tr" - else -> "en" - } - - val textTrackIndex = player.trackInfo.indexOfFirst { it.trackType == MediaPlayer.TrackInfo.MEDIA_TRACK_TYPE_TIMEDTEXT } - player.selectTrack(textTrackIndex + captionLanguageMapping.indexOfFirst { it.first == captionSuffix }) - } - - val surfaceCallback = remember { - object : SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - player.prepare() - player.start() - player.setDisplay(holder) - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} - - override fun surfaceDestroyed(holder: SurfaceHolder) { - player.stop() - player.setDisplay(null) - } - } - } - - var size by remember { mutableStateOf(IntSize(100, 100)) } - - var showCaption by remember { mutableStateOf(true) } - - Column { - Box(modifier = Modifier.clip(RoundedCornerShape(8.dp))) { - AndroidView( - factory = { ctx -> - val view = SurfaceView(ctx) - - view.holder.addCallback(surfaceCallback) - - view - }, - modifier = Modifier - .fillMaxSize() - .aspectRatio(aspect) - .onSizeChanged { - size = it - } - ) { - it.updateLayoutParams { - this.height = size.width - this.width = size.height - } - } - - if (showCaption) { - Surface( - contentColor = Color.White, - color = Color.Black.copy(alpha = 0.8f), - modifier = Modifier - .align(Alignment.BottomCenter) - .wrapContentHeight() - .fillMaxWidth() - ) { - Text(caption, style = MaterialTheme.typography.subtitle1, textAlign = TextAlign.Center) - } - } - } - - IconToggleButton( - checked = showCaption, - onCheckedChange = { showCaption = it }, - modifier = Modifier.align(Alignment.End) - ) { - when (showCaption) { - true -> Icon(Icons.Rounded.ClosedCaption, null) - false -> Icon(Icons.Rounded.ClosedCaptionDisabled, null) - } - } - Spacer16() - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallLoginSwitch.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallLoginSwitch.kt deleted file mode 100644 index 8633a620..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallLoginSwitch.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.ui - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallSwitchNavigation -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.SpacerMedium - -@Preview -@Composable -fun SwitchPreview() { - AppTheme { - var navSelection: CardWallSwitchNavigation = CardWallSwitchNavigation.NO_ROUTE - - CardWallAuthenticationChooser( - navSelection = navSelection, - onSelected = { onSelection -> navSelection = onSelection }, - hasNfc = true, - ) - } -} - -@Composable -fun CardWallAuthenticationChooser( - navSelection: CardWallSwitchNavigation, - onSelected: (CardWallSwitchNavigation) -> Unit, - hasNfc: Boolean -) { - - Column { - Column(Modifier.padding(PaddingDefaults.Medium)) { - Text( - text = stringResource(id = R.string.cdw_register_title), - style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = PaddingDefaults.Small) - ) - Text( - text = stringResource(id = R.string.cdw_register_body), - style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground - ) - } - SelectableCard( - image = { CardImagePainter(R.drawable.man_register, stringResource(R.string.cdw_man_register_accessibility)) }, - header = stringResource(id = R.string.cdw_register_with_healthy_card), - info = stringResource(id = R.string.cdw_register_healty_card_info), - selected = when (navSelection) { CardWallSwitchNavigation.INTRO -> true; else -> false }, - onCardSelected = { onSelected(CardWallSwitchNavigation.INTRO) }, - enabled = hasNfc, - ) - if (!hasNfc) Text( - text = stringResource(id = R.string.cdw_no_nfc), - modifier = Modifier.padding(horizontal = PaddingDefaults.Large), - color = Color.Red, - fontSize = 10.sp, - ) - SpacerMedium() - // todo: Change img, header and enable card when fasttrack is live - SelectableCard( - image = { CardImagePainter(R.drawable.ic_construction_android, stringResource(R.string.cdw_woman_register_accessibility)) }, - header = stringResource(id = R.string.cdw_register_with_health_insurance), - info = stringResource(id = R.string.cdw_register_health_insurance_info), - selected = when (navSelection) { CardWallSwitchNavigation.INSURANCE_APP -> true; else -> false }, - onCardSelected = { onSelected(CardWallSwitchNavigation.INSURANCE_APP) }, - enabled = true - ) - } -} - -@Composable -fun CardImagePainter(@DrawableRes drawableId: Int, description: String) { - val painter = painterResource(id = drawableId) - Image(painter = painter, contentDescription = description, contentScale = ContentScale.Fit, modifier = Modifier.size(80.dp)) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcInstructionScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcInstructionScreen.kt new file mode 100644 index 00000000..f3668a85 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcInstructionScreen.kt @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.HelpOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.cardwall.ui.model.CardWallNfcPositionViewModelData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.NavigationBack +import de.gematik.ti.erp.app.utils.compose.SpacerTiny +import de.gematik.ti.erp.app.utils.compose.TopAppBar +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import org.kodein.di.compose.rememberViewModel +import kotlin.math.PI +import kotlin.math.cos + +private const val LowerPos = 1 / 3f +private const val HigherPos = 2 / 3f +private val PosRange = LowerPos..HigherPos + +@Stable +sealed class CardWallAuthenticationData( + val cardAccessNumber: String, + val personalIdentificationNumber: String +) { + + @Stable + class HealthCard( + cardAccessNumber: String, + personalIdentificationNumber: String + ) : CardWallAuthenticationData( + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber + ) + + @Stable + class AltPairingWithHealthCard( + cardAccessNumber: String, + personalIdentificationNumber: String, + val initialPairingData: AltPairingProvider.AuthResult.Initialized + ) : CardWallAuthenticationData( + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber + ) +} + +@Composable +fun CardWallNfcInstructionScreen( + onClickTroubleshooting: () -> Unit, + onBack: () -> Unit, + viewModel: CardWallViewModel, + authenticationData: CardWallAuthenticationData, + profileId: ProfileIdentifier, + onNext: () -> Unit, + onUnlockEgk: () -> Unit, + onRetryCan: () -> Unit, + onRetryPin: () -> Unit +) { + val nfcPositionViewModel by rememberViewModel() + val state by remember { mutableStateOf(nfcPositionViewModel.screenState()) } + + val dialogState = rememberCardWallAuthenticationDialogState() + + if (!viewModel.isNFCEnabled()) { + EnableNfcDialog { + onBack() + } + } else { + CardWallAuthenticationDialog( + dialogState = dialogState, + viewModel = viewModel, + authenticationData = authenticationData, + profileId = profileId, + troubleShootingEnabled = true, + allowUserCancellation = true, + onFinal = onNext, + onUnlockEgk = onUnlockEgk, + onRetryCan = onRetryCan, + onRetryPin = onRetryPin, + onClickTroubleshooting = onClickTroubleshooting + ) + } + + NFCInstructionScreen(onBack, onClickTroubleshooting, state) +} + +@Composable +fun NFCInstructionScreen( + onBack: () -> Unit, + onClickTroubleshooting: () -> Unit, + state: CardWallNfcPositionViewModelData.NfcPosition +) { + val useDarkIcons = MaterialTheme.colors.isLight + AppTheme( + darkTheme = true + ) { + val systemUiController = rememberSystemUiController() + DisposableEffect(Unit) { + systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = false) + onDispose { systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = useDarkIcons) } + } + Scaffold( + modifier = Modifier.testTag(TestTag.CardWall.Nfc.NfcScreen), + topBar = { + TopAppBar( + title = {}, + backgroundColor = MaterialTheme.colors.background, + modifier = Modifier, + navigationIcon = { NavigationBack { onBack() } }, + actions = { TroubleshootButton(onTroubleshooting = onClickTroubleshooting) } + ) + } + ) { + val lazyListState = rememberLazyListState() + var phoneImgSize by remember { mutableStateOf(IntSize.Zero) } + var titleHeight by remember { mutableStateOf(0) } + var subTitleHeight by remember { mutableStateOf(0) } + var descriptionHeight by remember { mutableStateOf(0) } + val nfcXPos by remember { mutableStateOf((state.nfcPosition.x0 + state.nfcPosition.x1) / 2) } + val nfcYPos by remember { mutableStateOf((state.nfcPosition.y0 + state.nfcPosition.y1) / 2) } + + LazyColumn( + state = lazyListState, + modifier = Modifier + .padding(it) + .fillMaxSize() + .navigationBarsPadding() + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.SpaceBetween + ) { + item { + Text( + stringResource(R.string.nfc_instruction_headline), + style = AppTheme.typography.h6, + modifier = Modifier + .padding( + all = PaddingDefaults.Medium + ) + .onGloballyPositioned { titleHeight = it.size.height } + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + + AnimatedVisibility( + modifier = Modifier.fillMaxWidth(), + visible = with(LocalDensity.current) { + (1.5f * phoneImgSize.height + titleHeight + subTitleHeight + descriptionHeight).toDp() + } <= LocalConfiguration.current.screenHeightDp.dp + ) { + Text( + stringResource(R.string.nfc_instruction_time_hint), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center, + modifier = Modifier + .padding( + bottom = PaddingDefaults.Large, + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + .onGloballyPositioned { subTitleHeight = it.size.height } + .fillMaxWidth() + ) + } + } + item { + CardOnPhone( + nfcXPos = nfcXPos, + nfcYPos = nfcYPos, + phoneImgSize = phoneImgSize, + onPhoneSizeChanged = { phoneImgSize = it } + ) + } + item { + val cardPosDescr = when { + nfcXPos < LowerPos && nfcYPos < LowerPos -> stringResource(R.string.nfc_instruction_chip_location_top_left) + nfcXPos < LowerPos && nfcYPos in PosRange -> stringResource(R.string.nfc_instruction_chip_location_middle_left) + nfcXPos < LowerPos && nfcYPos > HigherPos -> stringResource(R.string.nfc_instruction_chip_location_bot_left) + nfcXPos in PosRange && nfcYPos < LowerPos -> stringResource(R.string.nfc_instruction_chip_location_top_central) + nfcXPos in PosRange && nfcYPos in PosRange -> stringResource(R.string.nfc_instruction_chip_location_middle) + nfcXPos in PosRange && nfcYPos > HigherPos -> stringResource(R.string.nfc_instruction_chip_location_bot_central) + nfcXPos > HigherPos && nfcYPos < LowerPos -> stringResource(R.string.nfc_instruction_chip_location_top_right) + nfcXPos > HigherPos && nfcYPos in PosRange -> stringResource(R.string.nfc_instruction_chip_location_middle_right) + nfcXPos > HigherPos && nfcYPos > HigherPos -> stringResource(R.string.nfc_instruction_chip_location_bot_right) + else -> "" + } + AnimatedVisibility( + modifier = Modifier.fillMaxWidth(), + visible = with(LocalDensity.current) { + (1.5f * phoneImgSize.height + titleHeight + subTitleHeight + descriptionHeight).toDp() + } <= LocalConfiguration.current.screenHeightDp.dp + ) { + Text( + annotatedStringResource( + R.string.nfc_instruction_chip_location, + cardPosDescr + ), + style = AppTheme.typography.subtitle2l, + modifier = Modifier + .padding( + vertical = PaddingDefaults.Large, + horizontal = PaddingDefaults.Medium + ) + .onGloballyPositioned { descriptionHeight = it.size.height } + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } + } +} + +@Composable +private fun TroubleshootButton(onTroubleshooting: () -> Unit) { + Button( + onClick = onTroubleshooting, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.Tiny), + enabled = true, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = AppTheme.colors.neutral050, + contentColor = AppTheme.colors.primary600 + ) + ) { + Icon(Icons.Rounded.HelpOutline, contentDescription = null) + SpacerTiny() + Text( + stringResource(R.string.nfc_instruction_help_button), + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun CardOnPhone( + nfcXPos: Double, + nfcYPos: Double, + phoneImgSize: IntSize, + onPhoneSizeChanged: (IntSize) -> Unit +) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + /* + The first part of the offset calculation (in front of +) is the shift in the x-axis + (x-shift: 1/3 * x * max width & 1/3 * x * max height). + The second part of the offset calculation (after +) is the shift in the y-axis + (y-shift: -2/3 * y * max width & 2/3 * y * max height). + Cos-function is used to swap the offset direction (cos(0 pi) = 1, cos(1 pi) = -1), + since the images are centered all functions were halved and inverted. + */ + CardAndAnimation( + modifier = Modifier.offset { + IntOffset( + x = ((phoneImgSize.width * -cos(nfcXPos * PI) / 6) + (phoneImgSize.width * cos(nfcYPos * PI) / 3)).toInt(), + y = ((phoneImgSize.height * -cos(nfcXPos * PI).toFloat() / 6) + (phoneImgSize.height * -cos(nfcYPos * PI).toFloat() / 3)).toInt() + ) + } + ) + PhoneWithScaling(modifier = Modifier.width(maxWidth * 2 / 3), onPhoneSizeChanged = onPhoneSizeChanged) + } +} + +@Composable +private fun CardAndAnimation(modifier: Modifier) { + val animationComposition = rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.animation_pulse_lottie)) + val healthCardLottie = rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.healthcard_lottie)) + val progress by animateLottieCompositionAsState( + animationComposition.value, + iterations = LottieConstants.IterateForever + ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + LottieAnimation( + animationComposition.value, + progress + ) + LottieAnimation( + healthCardLottie.value + ) + } +} + +@Composable +private fun PhoneWithScaling(modifier: Modifier, onPhoneSizeChanged: (IntSize) -> Unit) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + val phoneLottie = rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_lottie)) + LottieAnimation( + phoneLottie.value, + contentScale = ContentScale.FillWidth, + modifier = Modifier.onGloballyPositioned { + onPhoneSizeChanged(it.size) + } + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcPositionViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcPositionViewModel.kt new file mode 100644 index 00000000..61128b93 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcPositionViewModel.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.ui + +import de.gematik.ti.erp.app.cardwall.ui.model.CardWallNfcPositionViewModelData +import de.gematik.ti.erp.app.cardwall.usecase.CardWallLoadNfcPositionUseCase +import androidx.lifecycle.ViewModel + +class CardWallNfcPositionViewModel( + private val nfcPositionUseCase: CardWallLoadNfcPositionUseCase +) : ViewModel() { + val defaultState = CardWallNfcPositionViewModelData.NfcPosition() + + private val findNfc = nfcPositionUseCase.findNfcPositionForPhone() + + fun screenState() = findNfc?.let { CardWallNfcPositionViewModelData.NfcPosition(findNfc) } ?: defaultState +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallScaffold.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallScaffold.kt index 8386e2f7..0469a318 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallScaffold.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallScaffold.kt @@ -18,64 +18,104 @@ package de.gematik.ti.erp.app.cardwall.ui -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold +import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.focused -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.core.LocalActivity import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton -import de.gematik.ti.erp.app.utils.compose.HintTextLearnMoreButton +import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.ClickableTaggedText +import de.gematik.ti.erp.app.utils.compose.HintTextActionButton import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar import de.gematik.ti.erp.app.utils.compose.SimpleCheck +import de.gematik.ti.erp.app.utils.compose.SpacerLarge import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.testId +import de.gematik.ti.erp.app.utils.compose.annotatedLinkStringLight @Composable fun CardWallIntroScaffold( - enableNext: Boolean, onNext: () -> Unit, - topColor: Color, - navigationMode: NavigationBarMode, - content: @Composable () -> Unit + onClickAlternateAuthentication: () -> Unit, + onClickOrderNow: () -> Unit, + actions: @Composable RowScope.() -> Unit = {} ) { val activity = LocalActivity.current val scrollState = rememberScrollState() AnimatedElevationScaffold( - modifier = Modifier.testTag("cardWall/intro"), - topBarTitle = stringResource(R.string.cdw_add_card), + modifier = Modifier.testTag(TestTag.CardWall.Login.LoginScreen) + .systemBarsPadding(), + topBarTitle = "", + elevated = scrollState.value > 0, + navigationMode = null, + actions = actions, + bottomBar = { + CardWallIntroBottomBar( + onNext = onNext, + onClickAlternateAuthentication = onClickAlternateAuthentication + ) + }, + onBack = { activity.onBackPressed() } + ) { + Box( + modifier = Modifier + .verticalScroll(scrollState) + .padding(it) + ) { + AddCardContent( + onClickOrderNow = onClickOrderNow + ) + } + } +} + +@Composable +fun CardWallInfoScaffold( + topColor: Color, + onNext: () -> Unit, + onCancel: () -> Unit, + content: @Composable () -> Unit +) { + val scrollState = rememberScrollState() + + AnimatedElevationScaffold( + modifier = Modifier.systemBarsPadding(), + topBarTitle = stringResource(R.string.cdw_info_title), topBarColor = topColor, elevated = scrollState.value > 0, - navigationMode = navigationMode, - bottomBar = { CardWallBottomBar(onNext, enableNext) }, - onBack = { activity.onBackPressed() }, + navigationMode = NavigationBarMode.Close, + actions = { }, + bottomBar = { + AlternativeInfoBottomBar( + onNext = onNext + ) + }, + onBack = { onCancel() } ) { Box( modifier = Modifier @@ -89,103 +129,107 @@ fun CardWallIntroScaffold( @Composable fun CardWallMissingCapabilities() { val activity = LocalActivity.current - Scaffold( - topBar = { - NavigationTopAppBar( - navigationMode = NavigationBarMode.Close, - title = stringResource(R.string.cdw_capability_title), - onBack = { activity.onBackPressed() } - ) - } - ) { - Column { - Box(modifier = Modifier.verticalScroll(rememberScrollState())) { - Column( - modifier = Modifier - .padding(it) - .padding(AppTheme.framePadding) - .semantics(true) { - focused = true - } - ) { - Image( - painterResource(id = R.drawable.oh_no), - null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth() - ) - SpacerSmall() - Text( - stringResource(R.string.cdw_capability_headline), - style = MaterialTheme.typography.h6 - ) - SpacerSmall() - Text( - stringResource(R.string.cdw_capability_body), - style = MaterialTheme.typography.body1 - ) - SpacerSmall() - Text( - stringResource(R.string.cdw_capability_more), - style = AppTheme.typography.body2l - ) - } - } - } - } -} + val scrollState = rememberScrollState() -@Composable -fun AddCardContent( - topColor: Color, - onClickLearnMore: () -> Unit -) { - Column { - Image( - painterResource(R.drawable.card_wall_man), - null, - alignment = Alignment.BottomStart, + AnimatedElevationScaffold( + modifier = Modifier.testTag("cardWall/intro") + .systemBarsPadding(), + topBarTitle = "", + elevated = scrollState.value > 0, + actions = @Composable { + TextButton(onClick = { activity.onBackPressed() }) { + Text(stringResource(R.string.cdw_missing_capabilities_close)) + } + }, + navigationMode = null, + onBack = {} + ) { + val uriHandler = LocalUriHandler.current + Column( modifier = Modifier + .verticalScroll(scrollState) + .padding(it) + .padding(PaddingDefaults.Medium) .fillMaxWidth() - .background(topColor) - .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) - ) - SpacerSmall() - Column(modifier = Modifier.padding(AppTheme.framePadding)) { + ) { Text( - stringResource(R.string.cdw_intro_title), - style = MaterialTheme.typography.h6, - modifier = Modifier.testId("cdw_txt_intro_header_bottom") + stringResource(R.string.cdw_missing_capabilities_title), + style = AppTheme.typography.h5 ) SpacerSmall() Text( - stringResource(R.string.cdw_intro_body), - style = MaterialTheme.typography.body1 + stringResource(R.string.cdw_missing_capabilities_info), + style = AppTheme.typography.body1 ) SpacerSmall() - HintTextLearnMoreButton() - SpacerMedium() - Text( - stringResource(R.string.cdw_intro_what_you_need), - style = MaterialTheme.typography.subtitle1 + ClickableTaggedText( + text = annotatedLinkStringLight( + uri = stringResource(R.string.cdw_missing_capabilities_link_to_faq), + text = stringResource(R.string.cdw_missing_capabilities_learn_more) + ), + onClick = { + uriHandler.openUri(it.item) + }, + style = AppTheme.typography.body2.merge( + TextStyle(textAlign = TextAlign.End) + ), + modifier = Modifier.align(Alignment.End) ) - SpacerMedium() - SimpleCheck(stringResource(R.string.cdw_intro_what_you_need_egk)) - SpacerMedium() - SimpleCheck(stringResource(R.string.cdw_intro_what_you_need_pin)) - SpacerMedium() - SimpleCheck(stringResource(R.string.cdw_intro_what_you_need_nfc)) + } + } +} + +@Composable +fun AddCardContent( + onClickOrderNow: () -> Unit +) { + Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { + Text( + stringResource(R.string.cdw_intro_header), + style = AppTheme.typography.h5, + modifier = Modifier.testTag("cdw_txt_intro_header_bottom") + ) + SpacerSmall() + Text( + stringResource(R.string.cdw_intro_info), + style = AppTheme.typography.body1 + ) + SpacerLarge() + Text( + stringResource(R.string.cdw_intro_what_you_need), + style = AppTheme.typography.subtitle1 + ) + SpacerMedium() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green500) SpacerMedium() Text( - stringResource(R.string.cdw_intro_what_you_need_no_egk), - style = MaterialTheme.typography.caption + stringResource(R.string.cdw_intro_nfc_card_needed), + style = AppTheme.typography.body1, + modifier = Modifier.weight(1f) ) - SpacerSmall() - Row(modifier = Modifier.align(Alignment.End)) { - HintTextActionButton(text = stringResource(R.string.learn_more_btn)) { - onClickLearnMore() - } - } + } + + SimpleCheck(stringResource(R.string.cdw_intro_pin_needed)) + SpacerMedium() + + Text( + text = stringResource(R.string.cdw_have_no_card_with_pin), + style = AppTheme.typography.body2l + ) + + HintTextActionButton( + text = stringResource(R.string.cdw_intro_order_now), + align = Alignment.End, + modifier = Modifier.align(Alignment.End) + ) { + onClickOrderNow() } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallSecret.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallSecret.kt new file mode 100644 index 00000000..3649b1b6 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallSecret.kt @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.IconToggleButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextFieldColors +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.pharmacy.ui.scrollOnFocus +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.ClickableTaggedText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.NavigationMode +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import de.gematik.ti.erp.app.utils.compose.annotatedLinkStringLight +import de.gematik.ti.erp.app.utils.compose.visualTestTag + +@Composable +fun CardWallSecretScreen( + navMode: NavigationMode, + secret: String, + secretRange: IntRange, + screenTitle: String, + onSecretChange: (String) -> Unit, + onCancel: () -> Unit, + nextText: String, + next: (String) -> Unit, + onBack: () -> Unit, + onClickNoPinReceived: () -> Unit +) { + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("cardWall/secretScreen"), + backMode = when (navMode) { + NavigationMode.Forward, + NavigationMode.Back, + NavigationMode.Closed -> NavigationBarMode.Back + NavigationMode.Open -> NavigationBarMode.Close + }, + title = screenTitle, + nextEnabled = secret.length in secretRange, + onNext = { next(secret) }, + nextText = nextText, + listState = lazyListState, + onBack = { onBack() }, + actions = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + } + ) { innerPadding -> + val contentPadding by derivedStateOf { + PaddingValues( + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + } + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding + ) { + item { + Text( + stringResource(R.string.cdw_pin_title), + style = AppTheme.typography.h5 + ) + + SpacerSmall() + } + item { + Text( + stringResource(R.string.cdw_pin_info), + style = AppTheme.typography.body1 + ) + SpacerSmall() + } + item { + ClickableTaggedText( + text = annotatedLinkStringLight( + uri = "", + text = stringResource(R.string.cdw_no_pin_received) + ), + onClick = { onClickNoPinReceived() }, + style = AppTheme.typography.body2 + ) + SpacerXXLarge() + } + item { + SecretInputField( + modifier = Modifier + .fillMaxWidth() + .scrollOnFocus(to = 3, lazyListState), + secretRange = secretRange, + onSecretChange = onSecretChange, + secret = secret, + label = stringResource(R.string.cdw_pin_label), + next = next + ) + } + } + } +} + +@Composable +fun SecretInputField( + modifier: Modifier, + secretRange: IntRange, + onSecretChange: (String) -> Unit, + secret: String, + label: String, + next: (String) -> Unit +) { + val secretRegex = """^\d{0,${secretRange.last}}$""".toRegex() + var secretVisible by remember { mutableStateOf(false) } + + OutlinedTextField( + modifier = modifier.visualTestTag(TestTag.CardWall.PIN.PINField), + value = secret, + onValueChange = { + if (it.matches(secretRegex)) { + onSecretChange(it) + } + }, + label = { Text(label) }, + visualTransformation = if (secretVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next + ), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedLabelColor = AppTheme.colors.neutral400, + placeholderColor = AppTheme.colors.neutral400, + trailingIconColor = AppTheme.colors.neutral400 + ), + keyboardActions = KeyboardActions { + next(secret) + }, + trailingIcon = { + IconToggleButton( + checked = secretVisible, + onCheckedChange = { secretVisible = it } + ) { + Icon( + if (secretVisible) { + Icons.Rounded.Visibility + } else { + Icons.Rounded.VisibilityOff + }, + null + ) + } + } + ) +} + +@Composable +fun ConformationSecretInputField( + modifier: Modifier, + secretRange: IntRange, + onSecretChange: (String) -> Unit, + secret: String, + repeatedSecret: String, + label: String, + isConsistent: Boolean, + next: (String) -> Unit +) { + val secretRegex = """^\d{0,${secretRange.last}}$""".toRegex() + var secretVisible by remember { mutableStateOf(false) } + + OutlinedTextField( + modifier = modifier, + value = repeatedSecret, + onValueChange = { + if (it.matches(secretRegex)) { + onSecretChange(it) + } + }, + label = { Text(label) }, + visualTransformation = if (secretVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.NumberPassword + ), + shape = RoundedCornerShape(8.dp), + colors = + if (repeatedSecret.isEmpty()) { + TextFieldDefaults.outlinedTextFieldColors( + unfocusedLabelColor = AppTheme.colors.neutral400, + placeholderColor = AppTheme.colors.neutral400, + trailingIconColor = AppTheme.colors.neutral400 + ) + } else { + if (isConsistent) { + textFieldColor(AppTheme.colors.green600) + } else { + textFieldColor(AppTheme.colors.red500) + } + }, + keyboardActions = KeyboardActions { + next(secret) + }, + trailingIcon = { + if (isConsistent) { + Icon( + Icons.Rounded.Check, + stringResource(R.string.consistent_password) + ) + } else { + IconToggleButton( + checked = secretVisible, + onCheckedChange = { secretVisible = it } + ) { + Icon( + if (secretVisible) { + Icons.Rounded.Visibility + } else { + Icons.Rounded.VisibilityOff + }, + null + ) + } + } + } + ) +} + +@Composable +private fun textFieldColor(color: Color): TextFieldColors { + return TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = color.copy( + alpha = ContentAlpha.high + ), + focusedLabelColor = color.copy( + alpha = ContentAlpha.high + ), + unfocusedBorderColor = color.copy(alpha = ContentAlpha.high), + unfocusedLabelColor = color.copy( + alpha = ContentAlpha.high + ), + trailingIconColor = color.copy( + alpha = ContentAlpha.high + ) + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallTroubleshooting.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallTroubleshooting.kt index c4bb8158..a59f0c31 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallTroubleshooting.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallTroubleshooting.kt @@ -18,364 +18,122 @@ package de.gematik.ti.erp.app.cardwall.ui -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Lightbulb -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Edit import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.google.accompanist.insets.navigationBarsPadding -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold -import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.SpacerLarge -import de.gematik.ti.erp.app.utils.compose.SpacerMedium -import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.annotatedLinkString -import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.coroutines.launch @Composable fun CardWallTroubleshootingPageA( viewModel: CardWallViewModel, - authenticationMethod: CardWallData.AuthenticationMethod, - cardAccessNumber: String, - personalIdentificationNumber: String, + authenticationData: CardWallAuthenticationData, + profileId: ProfileIdentifier, onFinal: () -> Unit, onNext: () -> Unit, onBack: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit ) { val dialogState = rememberCardWallAuthenticationDialogState() CardWallAuthenticationDialog( + profileId = profileId, dialogState = dialogState, viewModel = viewModel, - authenticationMethod = authenticationMethod, - cardAccessNumber = cardAccessNumber, - personalIdentificationNumber = personalIdentificationNumber, + authenticationData = authenticationData, onFinal = onFinal, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) - val coroutineScope = rememberCoroutineScope() - - TroubleshootingScaffold( - title = stringResource(R.string.cdw_troubleshooting_page_a_title), + TroubleshootingPageAContent( onBack = onBack, - bottomBarButton = { NextTipButton(onClick = onNext) } - ) { - Column { - Tip(stringResource(R.string.cdw_troubleshooting_page_a_tip1)) - SpacerMedium() - Tip(stringResource(R.string.cdw_troubleshooting_page_a_tip2)) - SpacerMedium() - Tip(stringResource(R.string.cdw_troubleshooting_page_a_tip3)) - SpacerLarge() - TryMeButton { - coroutineScope.launch { dialogState.show() } - } + onNext = onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } } - } + ) } @Composable fun CardWallTroubleshootingPageB( viewModel: CardWallViewModel, - authenticationMethod: CardWallData.AuthenticationMethod, - cardAccessNumber: String, - personalIdentificationNumber: String, + authenticationData: CardWallAuthenticationData, + profileId: ProfileIdentifier, onFinal: () -> Unit, onNext: () -> Unit, onBack: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit ) { val dialogState = rememberCardWallAuthenticationDialogState() CardWallAuthenticationDialog( dialogState = dialogState, viewModel = viewModel, - authenticationMethod = authenticationMethod, - cardAccessNumber = cardAccessNumber, - personalIdentificationNumber = personalIdentificationNumber, + authenticationData = authenticationData, + profileId = profileId, onFinal = onFinal, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) val coroutineScope = rememberCoroutineScope() - - TroubleshootingScaffold( - title = stringResource(R.string.cdw_troubleshooting_page_b_title), - onBack = onBack, - bottomBarButton = { NextTipButton(onClick = onNext) } - ) { - Column { - Tip(stringResource(R.string.cdw_troubleshooting_page_b_tip1)) - SpacerMedium() - Tip(stringResource(R.string.cdw_troubleshooting_page_b_tip2)) - SpacerLarge() - TryMeButton { - coroutineScope.launch { dialogState.show() } - } + TroubleshootingPageBContent( + onBack, + onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } } - } + ) } @Composable fun CardWallTroubleshootingPageC( viewModel: CardWallViewModel, - authenticationMethod: CardWallData.AuthenticationMethod, - cardAccessNumber: String, - personalIdentificationNumber: String, + authenticationData: CardWallAuthenticationData, + profileId: ProfileIdentifier, onFinal: () -> Unit, onNext: () -> Unit, onBack: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit ) { val dialogState = rememberCardWallAuthenticationDialogState() CardWallAuthenticationDialog( + profileId = profileId, dialogState = dialogState, viewModel = viewModel, - authenticationMethod = authenticationMethod, - cardAccessNumber = cardAccessNumber, - personalIdentificationNumber = personalIdentificationNumber, + authenticationData = authenticationData, onFinal = onFinal, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) val coroutineScope = rememberCoroutineScope() - - TroubleshootingScaffold( - title = stringResource(R.string.cdw_troubleshooting_page_c_title), - onBack = onBack, - bottomBarButton = { NextButton(onClick = onNext) } - ) { - Column { - val uriHandler = LocalUriHandler.current - - val tip1 = annotatedStringResource( - R.string.cdw_troubleshooting_page_c_tip1, - annotatedLinkString( - stringResource(R.string.cdw_troubleshooting_page_c_tip1_samsung_url), - stringResource(R.string.cdw_troubleshooting_page_c_tip1_samsung) - ) - ) - - val tip2 = annotatedStringResource( - R.string.cdw_troubleshooting_page_c_tip2, - annotatedLinkString( - stringResource(R.string.cdw_troubleshooting_page_c_tip2_google_url), - stringResource(R.string.cdw_troubleshooting_page_c_tip2_google) - ) - ) - - Tip(tip1, onClickText = { tag, item -> - when (tag) { - "URL" -> uriHandler.openUri(item) - } - }) - SpacerMedium() - Tip(tip2, onClickText = { tag, item -> - when (tag) { - "URL" -> uriHandler.openUri(item) - } - }) - SpacerLarge() - TryMeButton { - coroutineScope.launch { dialogState.show() } - } + TroubleshootingPageCContent( + onBack, + onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } } - } + ) } @Composable fun CardWallTroubleshootingNoSuccessPage( - onClickContactUs: () -> Unit, onNext: () -> Unit, - onBack: () -> Unit, + onBack: () -> Unit ) { - TroubleshootingScaffold( - title = stringResource(R.string.cdw_troubleshooting_no_success_title), - onBack = onBack, - bottomBarButton = { CloseButton(onClick = onNext) } - ) { - Column { - Text( - text = stringResource(R.string.cdw_troubleshooting_no_success_body), - style = MaterialTheme.typography.body1 - ) - SpacerLarge() - ContactUsButton(Modifier.align(Alignment.CenterHorizontally), onClick = onClickContactUs) - } - } -} - -@Composable -private fun RowScope.NextTipButton( - onClick: () -> Unit -) = - SecondaryButton( - onClick = onClick, - modifier = Modifier - .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) - .weight(1f) - ) { - Icon(Icons.Outlined.Lightbulb, null) - SpacerSmall() - Text(stringResource(R.string.cdw_troubleshooting_next_tip_button)) - } - -@Composable -private fun RowScope.NextButton( - onClick: () -> Unit -) = - SecondaryButton( - onClick = onClick, - modifier = Modifier - .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) - .weight(1f) - ) { - Text(stringResource(R.string.cdw_troubleshooting_next_button)) - } - -@Composable -private fun RowScope.CloseButton( - onClick: () -> Unit -) = - PrimaryButton( - onClick = onClick, - modifier = Modifier - .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) - .weight(1f) - ) { - Text(stringResource(R.string.cdw_troubleshooting_close_button)) - } - -@Composable -private fun ColumnScope.TryMeButton( - onClick: () -> Unit -) = - PrimaryButton( - onClick = onClick, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Text(stringResource(R.string.cdw_troubleshooting_try_me_button)) - } - -@Composable -private fun ContactUsButton( - modifier: Modifier, - onClick: () -> Unit -) = - SecondaryButton( - onClick = onClick, - modifier = modifier - ) { - Icon(Icons.Rounded.Edit, null) - SpacerSmall() - Text(stringResource(R.string.cdw_troubleshooting_contact_us_button)) - } - -@Composable -private fun Tip( - text: String -) = - Tip(AnnotatedString(text)) { _, _ -> } - -@Composable -private fun Tip( - text: AnnotatedString, - onClickText: (tag: String, item: String) -> Unit -) { - Row(Modifier.fillMaxWidth()) { - Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green600) - SpacerMedium() - ClickableText( - text = text, - style = MaterialTheme.typography.body1, - onClick = { offset -> - text - .getStringAnnotations(offset, offset) - .firstOrNull()?.let { - onClickText(it.tag, it.item) - } - } - ) - } -} - -@Composable -private fun TroubleshootingScaffold( - title: String, - onBack: () -> Unit, - bottomBarButton: @Composable RowScope.() -> Unit, - content: @Composable () -> Unit -) { - val scrollState = rememberScrollState() - - AnimatedElevationScaffold( - modifier = Modifier.testTag("cardWall/intro"), - topBarTitle = stringResource(R.string.cdw_troubleshooting_title), - topBarColor = MaterialTheme.colors.surface, - elevated = scrollState.value > 0, - navigationMode = NavigationBarMode.Back, - bottomBar = { - Surface( - color = MaterialTheme.colors.surface, - elevation = 4.dp - ) { - Row(Modifier.navigationBarsPadding()) { - bottomBarButton() - } - } - }, - onBack = onBack - ) { - Column( - modifier = Modifier - .verticalScroll(scrollState) - .padding(it) - .padding(PaddingDefaults.Medium) - ) { - Text( - title, - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - SpacerLarge() - content() - } - } + TroubleshootingNoSuccessPageContent( + onNext, + onBack + ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModel.kt index 4873a3dd..071dabab 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModel.kt @@ -20,148 +20,76 @@ package de.gematik.ti.erp.app.cardwall.ui import android.nfc.Tag import android.os.Build -import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcHealthCard import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationState import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase import de.gematik.ti.erp.app.cardwall.usecase.CardWallUseCase -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.featuretoggle.FeatureToggleManager -import de.gematik.ti.erp.app.featuretoggle.Features -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize -import javax.inject.Inject -private const val navStateKey = "cdwNavState" - -@HiltViewModel -class CardWallViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, +class CardWallViewModel( private val cardWallUseCase: CardWallUseCase, private val authenticationUseCase: AuthenticationUseCase, - private val dispatchProvider: DispatchProvider, - private val demoUseCase: DemoUseCase, - private val profilesUseCase: ProfilesUseCase, - private val toggleManager: FeatureToggleManager -) : BaseViewModel() { - @Parcelize - private data class NavState( - val can: String, - val pin: String, - val authMethod: CardWallData.AuthenticationMethod - ) : Parcelable + private val dispatchers: DispatchProvider +) : ViewModel() { val defaultState = CardWallData.State( - hardwareRequirementsFulfilled = cardWallUseCase.deviceHasNFCAndAndroidMOrHigher, - isIntroSeenByUser = cardWallUseCase.cardWallIntroIsAccepted, - cardAccessNumber = "", - selectedAuthenticationMethod = CardWallData.AuthenticationMethod.None, - personalIdentificationNumber = "", - demoMode = demoUseCase.demoModeActive.value, - ) - - private val navState = MutableStateFlow( - savedStateHandle.get(navStateKey) ?: NavState( - can = defaultState.cardAccessNumber, - pin = defaultState.personalIdentificationNumber, - authMethod = defaultState.selectedAuthenticationMethod, - ) + hardwareRequirementsFulfilled = cardWallUseCase.deviceHasNFCAndAndroidMOrHigher ) - init { - viewModelScope.launch { - if (!savedStateHandle.contains(navStateKey)) { - onSelectAuthenticationMethod( - cardWallUseCase.getAuthenticationMethod( - profilesUseCase.activeProfileName().first() - ) - ) - } - val can = cardWallUseCase.cardAccessNumber().first() ?: "" - onCardAccessNumberChange(can) - navState.collect { - savedStateHandle.set(navStateKey, it) - } - } - } - - fun state(): Flow = - combine( - navState, - demoUseCase.demoModeActive - ) { navState, demo -> - defaultState.copy( - cardAccessNumber = navState.can, - personalIdentificationNumber = navState.pin, - selectedAuthenticationMethod = navState.authMethod, - demoMode = demo - ) - } + fun state(): Flow = flowOf(defaultState) fun doAuthentication( - can: String, - pin: String, - method: CardWallData.AuthenticationMethod, + profileId: ProfileIdentifier, + authenticationData: CardWallAuthenticationData, tag: Flow ): Flow { val cardChannel = tag.map { NfcHealthCard.connect(it) } - return when { - method == CardWallData.AuthenticationMethod.None -> error("Authentication method must be set") - method == CardWallData.AuthenticationMethod.Alternative && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> - authenticationUseCase.pairDeviceWithHealthCardAndSecureElement( - can = can, - pin = pin, - cardChannel = cardChannel - ) - else -> + return when (authenticationData) { + is CardWallAuthenticationData.AltPairingWithHealthCard -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + authenticationUseCase.pairDeviceWithHealthCardAndSecureElement( + profileId = profileId, + can = authenticationData.cardAccessNumber, + pin = authenticationData.personalIdentificationNumber, + publicKeyOfSecureElementEntry = authenticationData.initialPairingData.publicKey, + aliasOfSecureElementEntry = authenticationData.initialPairingData.aliasOfSecureElementEntry, + cardChannel = cardChannel + ).onEach { + if (it.isFinal()) { + // silent fail; user has the alternative on the main screen + authenticationUseCase.authenticateWithSecureElement( + profileId = profileId, + scope = IdpScope.Default + ).collect { + Napier.d { "Auth after pairing: $it" } + } + } + } + } else { + error("Can't use biometric authentication below Android P") + } + + is CardWallAuthenticationData.HealthCard -> authenticationUseCase.authenticateWithHealthCard( - can = can, - pin = pin, + profileId = profileId, + can = authenticationData.cardAccessNumber, + pin = authenticationData.personalIdentificationNumber, cardChannel = cardChannel ) } - .onEach { - if (it.isFinal()) { - cardWallUseCase.setCardAccessNumber(can) - } - } - .flowOn(dispatchProvider.io()) - } - - fun onCardAccessNumberChange(can: String) { - navState.value = navState.value.copy(can = can) - } - - fun onPersonalIdentificationChange(pin: String) { - navState.value = navState.value.copy(pin = pin) - } - - fun onSelectAuthenticationMethod(authMethod: CardWallData.AuthenticationMethod) { - navState.value = navState.value.copy(authMethod = authMethod) - } - - fun onIntroSeenByUser() { - cardWallUseCase.cardWallIntroIsAccepted = true + .flowOn(dispatchers.IO) } fun isNFCEnabled() = cardWallUseCase.deviceHasNFCEnabled - - fun fastTrackOn() = - toggleManager.isFeatureEnabled(Features.FAST_TRACK.featureName) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListScreen.kt index bfa4edd6..86b13173 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListScreen.kt @@ -18,75 +18,297 @@ package de.gematik.ti.erp.app.cardwall.ui -import android.content.Intent -import android.net.Uri -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.material.Button +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import de.gematik.ti.erp.app.BuildConfig -import de.gematik.ti.erp.app.cardwall.ui.model.ExternalAuthenticatorListViewModel -import de.gematik.ti.erp.app.idp.api.models.AuthenticationID +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.LocalIntentHandler +import de.gematik.ti.erp.app.idp.api.models.AuthenticationId +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import org.kodein.di.compose.rememberViewModel -@OptIn(ExperimentalAnimationApi::class) @Composable fun ExternalAuthenticatorListScreen( - mainNavController: NavController, - viewModel: ExternalAuthenticatorListViewModel = hiltViewModel() + profileId: ProfileIdentifier, + onNext: () -> Unit, + onCancel: () -> Unit, + onBack: () -> Unit ) { - val navController = rememberNavController() - val redirectScope = rememberCoroutineScope() - val externalAuthenticatorList by produceState( - initialValue = emptyList(), - producer = { - value = if (BuildConfig.DEBUG) - listOf(AuthenticationID("Test_Krankenkasse", "test_authentication_id")) - else - viewModel.externalAuthenticatorIDList() + val viewModel by rememberViewModel() + val listState = rememberLazyListState() + AnimatedElevationScaffold( + navigationMode = NavigationBarMode.Back, + topBarTitle = stringResource(R.string.cdw_fasttrack_title), + onBack = onBack, + listState = listState, + actions = @Composable { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } } + ) { + AuthenticatorList( + profileId = profileId, + viewModel = viewModel, + onNext = onNext, + listState = listState + ) + } +} + +private val WhitespaceRegex = "\\s+".toRegex() + +@Composable +private fun rememberFilteredAuthenticatorsList( + source: List, + keywords: String +): State> { + val result = remember(source) { mutableStateOf(source) } + LaunchedEffect(source, keywords) { + result.value = if (keywords.isNotBlank()) { + val kw = keywords.split(WhitespaceRegex) + source.filter { src -> + kw.all { src.name.contains(it, ignoreCase = true) } + } + } else { + source + } + } + return result +} + +@Stable +private sealed interface RefreshState { + @Stable + object Loading : RefreshState + + @Stable + class WithResults(val result: List) : RefreshState + + @Stable + class Error(val throwable: Throwable) : RefreshState +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AuthenticatorList( + profileId: ProfileIdentifier, + viewModel: ExternalAuthenticatorListViewModel, + onNext: () -> Unit, + listState: LazyListState +) { + val refreshFlow = remember { MutableSharedFlow() } + var state by remember { mutableStateOf(RefreshState.Loading) } + LaunchedEffect(Unit) { + refreshFlow + .onStart { emit(Unit) } // emit once to start the flow directly + .collectLatest { + state = RefreshState.Loading + state = try { + RefreshState.WithResults(viewModel.externalAuthenticatorIDList()) + } catch (expected: Throwable) { + RefreshState.Error(expected) + } + } + } + + val coroutineScope = rememberCoroutineScope() + + var search by remember { mutableStateOf("") } + val externalAuthenticatorListFiltered by rememberFilteredAuthenticatorsList( + source = (state as? RefreshState.WithResults)?.result ?: emptyList(), + keywords = search ) - val context = LocalContext.current - LazyColumn( + val intentHandler = LocalIntentHandler.current + + Column(Modifier.fillMaxSize()) { + Column(Modifier.padding(PaddingDefaults.Medium)) { + Text(stringResource(R.string.cdw_fasttrack_choose_insurance), style = MaterialTheme.typography.h6) + SpacerSmall() + Text( + stringResource(R.string.cdw_fasttrack_help_info), + style = AppTheme.typography.body2l + ) + } + when (state) { + is RefreshState.Loading -> { + Box( + Modifier + .fillMaxSize() + .padding(PaddingDefaults.Medium) + ) { + CircularProgressIndicator( + Modifier + .size(32.dp) + .align(Alignment.Center) + ) + } + } + is RefreshState.Error -> { + ErrorScreen( + onClickRetry = { + coroutineScope.launch { + refreshFlow.emit(Unit) + } + } + ) + } + is RefreshState.WithResults -> { + SpacerLarge() + SearchField( + value = search, + onValueChange = { + search = it + } + ) + SpacerMedium() + LazyColumn( + modifier = Modifier + .fillMaxSize(), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + items(externalAuthenticatorListFiltered) { + Surface( + modifier = Modifier.fillMaxWidth(), + onClick = { + coroutineScope.launch { + val redirectUri = + viewModel.startAuthorizationWithExternal( + profileId = profileId, + auth = it + ) + + intentHandler.startFastTrackApp(redirectUri) + + onNext() + } + } + ) { + Text(text = it.name, modifier = Modifier.padding(PaddingDefaults.Medium)) + } + } + } + } + } + } +} + +@Composable +private fun SearchField( + value: String, + onValueChange: (String) -> Unit +) = + OutlinedTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, modifier = Modifier .fillMaxWidth() - .padding( - all = PaddingDefaults.Medium + .padding(horizontal = PaddingDefaults.Medium), + placeholder = { + Text( + stringResource(R.string.cdw_fasttrack_search_placeholder), + style = AppTheme.typography.body1l ) + }, + shape = RoundedCornerShape(16.dp), + leadingIcon = { Icon(Icons.Rounded.Search, null) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + backgroundColor = AppTheme.colors.neutral100, + placeholderColor = AppTheme.colors.neutral600, + leadingIconColor = AppTheme.colors.neutral600, + focusedBorderColor = Color.Unspecified, + unfocusedBorderColor = Color.Unspecified, + disabledBorderColor = Color.Unspecified, + errorBorderColor = Color.Unspecified + ) + ) + +@Composable +private fun ErrorScreen( + onClickRetry: () -> Unit +) = + Box( + Modifier + .fillMaxSize() + .padding(PaddingDefaults.Medium) ) { - items(externalAuthenticatorList) { - Button( - modifier = Modifier.padding( - all = PaddingDefaults.Medium - ), - onClick = { - redirectScope.launch { - val redirectUri = - if (BuildConfig.DEBUG) - Uri.parse("https://kk.dev.gematik.solutions?client_id=smartcardIdp&state=0f27adbd1ca19d807b31bc786ee17872&redirect_uri=https%3A%2F%2Fdas-e-rezept-fuer-deutschland.de%2Fextauth&code_challenge=8ieJJp-xeDBcz1yscBV_xEqnbkSqKQfxPvkfr_XjsaE&code_challenge_method=S256&response_type=code&nonce=eb509ac2910e82acddfb8a88827eb705&scope=erp_sek_auth%2Bopenid") - else - viewModel.startAuthorizationWithExternal(it.authenticationID) - - context.startActivity(Intent(Intent.ACTION_VIEW, redirectUri)) - } - } + Column( + modifier = Modifier.align(BiasAlignment(horizontalBias = 0f, verticalBias = -0.33f)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + Text( + stringResource(R.string.cdw_fasttrack_error_title), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + Text( + stringResource(R.string.cdw_fasttrack_error_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + TextButton( + onClick = onClickRetry ) { - Text(text = it.name) + Icon(Icons.Rounded.Refresh, null) + SpacerSmall() + Text(stringResource(R.string.cdw_fasttrack_try_again)) } } } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/ExternalAuthenticatorListViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListViewModel.kt similarity index 58% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/ExternalAuthenticatorListViewModel.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListViewModel.kt index 3380469e..58186fa4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/ExternalAuthenticatorListViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/ExternalAuthenticatorListViewModel.kt @@ -16,28 +16,29 @@ * */ -package de.gematik.ti.erp.app.cardwall.ui.model +package de.gematik.ti.erp.app.cardwall.ui -import android.net.Uri -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.core.BaseViewModel +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.idp.api.models.AuthenticationId import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.coroutines.withContext -import javax.inject.Inject +import java.net.URI -@HiltViewModel -class ExternalAuthenticatorListViewModel @Inject constructor( +class ExternalAuthenticatorListViewModel( private val idpUseCase: IdpUseCase, - private val dispatchProvider: DispatchProvider -) : BaseViewModel() { + private val dispatchers: DispatchProvider +) : ViewModel() { - suspend fun externalAuthenticatorIDList() = withContext(dispatchProvider.io()) { - idpUseCase.downloadDiscoveryDocumentAndGetExternAuthenticatorIDs() + suspend fun externalAuthenticatorIDList() = withContext(dispatchers.IO) { + idpUseCase.loadExternAuthenticatorIDs() } - suspend fun startAuthorizationWithExternal(id: String): Uri = - idpUseCase.getUniversalLinkForExternalAuthorization(id) + suspend fun startAuthorizationWithExternal(profileId: ProfileIdentifier, auth: AuthenticationId): URI = + idpUseCase.getUniversalLinkForExternalAuthorization( + profileId = profileId, + authenticatorId = auth.id, + authenticatorName = auth.name + ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/TroubleshootingContent.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/TroubleshootingContent.kt new file mode 100644 index 00000000..96ae23f7 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/TroubleshootingContent.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.navigationBarsPadding +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.settings.ui.buildFeedbackBodyWithDeviceInfo +import de.gematik.ti.erp.app.settings.ui.openMailClient +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.ClickableTaggedText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.PrimaryButton +import de.gematik.ti.erp.app.utils.compose.SecondaryButton +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.annotatedLinkString +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource + +@Composable +fun TroubleshootingPageAContent( + onBack: () -> Unit, + onNext: () -> Unit, + onClickTryMe: () -> Unit +) { + TroubleshootingScaffold( + title = stringResource(R.string.cdw_troubleshooting_page_a_title), + onBack = onBack, + bottomBarButton = { NextTipButton(onClick = onNext) } + ) { + Column { + Tip(stringResource(R.string.cdw_troubleshooting_page_a_tip1)) + SpacerMedium() + Tip(stringResource(R.string.cdw_troubleshooting_page_a_tip2)) + SpacerMedium() + Tip(stringResource(R.string.cdw_troubleshooting_page_a_tip3)) + SpacerLarge() + TryMeButton { + onClickTryMe() + } + } + } +} + +@Composable +fun TroubleshootingPageBContent( + onBack: () -> Unit, + onNext: () -> Unit, + onClickTryMe: () -> Unit +) { + TroubleshootingScaffold( + title = stringResource(R.string.cdw_troubleshooting_page_b_title), + onBack = onBack, + bottomBarButton = { NextTipButton(onClick = onNext) } + ) { + Column { + Tip(stringResource(R.string.cdw_troubleshooting_page_b_tip1)) + SpacerMedium() + Tip(stringResource(R.string.cdw_troubleshooting_page_b_tip2)) + SpacerLarge() + TryMeButton { + onClickTryMe() + } + } + } +} + +@Composable +fun TroubleshootingPageCContent( + onBack: () -> Unit, + onNext: () -> Unit, + onClickTryMe: () -> Unit +) { + TroubleshootingScaffold( + title = stringResource(R.string.cdw_troubleshooting_page_c_title), + onBack = onBack, + bottomBarButton = { NextButton(onClick = onNext) } + ) { + Column { + val uriHandler = LocalUriHandler.current + + val tip1 = annotatedStringResource( + R.string.cdw_troubleshooting_page_c_tip1, + annotatedLinkString( + stringResource(R.string.cdw_troubleshooting_page_c_tip1_samsung_url), + stringResource(R.string.cdw_troubleshooting_page_c_tip1_samsung) + ) + ) + + val tip2 = annotatedStringResource( + R.string.cdw_troubleshooting_page_c_tip2, + annotatedLinkString( + stringResource(R.string.cdw_troubleshooting_page_c_tip2_google_url), + stringResource(R.string.cdw_troubleshooting_page_c_tip2_google) + ) + ) + + Tip(tip1, onClickText = { tag, item -> + when (tag) { + "URL" -> uriHandler.openUri(item) + } + }) + SpacerMedium() + Tip(tip2, onClickText = { tag, item -> + when (tag) { + "URL" -> uriHandler.openUri(item) + } + }) + SpacerLarge() + TryMeButton { + onClickTryMe() + } + } + } +} + +@Composable +fun TroubleshootingNoSuccessPageContent( + onNext: () -> Unit, + onBack: () -> Unit +) { + TroubleshootingScaffold( + title = stringResource(R.string.cdw_troubleshooting_no_success_title), + onBack = onBack, + bottomBarButton = { CloseButton(onClick = onNext) } + ) { + val context = LocalContext.current + val mailAddress = stringResource(R.string.settings_contact_mail_address) + val subject = stringResource(R.string.settings_feedback_mail_subject) + val body = buildFeedbackBodyWithDeviceInfo(context = context) + + Column { + Text( + text = stringResource(R.string.cdw_troubleshooting_no_success_body), + style = AppTheme.typography.body1 + ) + SpacerLarge() + ContactUsButton( + Modifier.align(Alignment.CenterHorizontally), + onClick = { + openMailClient(context, mailAddress, body, subject) + } + ) + } + } +} + +@Composable +private fun RowScope.NextTipButton( + onClick: () -> Unit +) = + SecondaryButton( + onClick = onClick, + modifier = Modifier + .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) + .weight(1f) + ) { + Icon(Icons.Outlined.Lightbulb, null) + SpacerSmall() + Text(stringResource(R.string.cdw_troubleshooting_next_tip_button)) + } + +@Composable +private fun RowScope.NextButton( + onClick: () -> Unit +) = + SecondaryButton( + onClick = onClick, + modifier = Modifier + .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) + .weight(1f) + ) { + Text(stringResource(R.string.cdw_troubleshooting_next_button)) + } + +@Composable +private fun RowScope.CloseButton( + onClick: () -> Unit +) = + PrimaryButton( + onClick = onClick, + modifier = Modifier + .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) + .weight(1f) + ) { + Text(stringResource(R.string.cdw_troubleshooting_close_button)) + } + +@Composable +private fun ColumnScope.TryMeButton( + onClick: () -> Unit +) = + PrimaryButton( + onClick = onClick, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text(stringResource(R.string.cdw_troubleshooting_try_me_button)) + } + +@Composable +private fun ContactUsButton( + modifier: Modifier, + onClick: () -> Unit +) = + SecondaryButton( + onClick = onClick, + modifier = modifier + ) { + Icon(Icons.Rounded.Edit, null) + SpacerSmall() + Text(stringResource(R.string.cdw_troubleshooting_contact_us_button)) + } + +@Composable +private fun Tip( + text: String +) = + Tip(AnnotatedString(text)) { _, _ -> } + +@Composable +private fun Tip( + text: AnnotatedString, + onClickText: (tag: String, item: String) -> Unit +) { + Row(Modifier.fillMaxWidth()) { + Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green600) + SpacerMedium() + ClickableTaggedText( + text = text, + style = AppTheme.typography.body1, + onClick = { + onClickText(it.tag, it.item) + } + ) + } +} + +@Composable +private fun TroubleshootingScaffold( + title: String, + onBack: () -> Unit, + bottomBarButton: @Composable RowScope.() -> Unit, + content: @Composable () -> Unit +) { + val scrollState = rememberScrollState() + + AnimatedElevationScaffold( + modifier = Modifier.testTag("cardWall/intro"), + topBarTitle = stringResource(R.string.cdw_troubleshooting_title), + topBarColor = MaterialTheme.colors.surface, + elevated = scrollState.value > 0, + actions = {}, + navigationMode = NavigationBarMode.Back, + bottomBar = { + Surface( + color = MaterialTheme.colors.surface, + elevation = 4.dp + ) { + Row(Modifier.navigationBarsPadding()) { + bottomBarButton() + } + } + }, + onBack = onBack + ) { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(it) + .padding(PaddingDefaults.Medium) + ) { + Text( + title, + style = AppTheme.typography.h6, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + SpacerLarge() + content() + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallData.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallData.kt index 55a6e4ce..458c3f91 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallData.kt @@ -29,13 +29,6 @@ object CardWallData { @Immutable data class State( - val hardwareRequirementsFulfilled: Boolean, - val isIntroSeenByUser: Boolean, - - val cardAccessNumber: String, - val personalIdentificationNumber: String, - val selectedAuthenticationMethod: AuthenticationMethod, - - val demoMode: Boolean + val hardwareRequirementsFulfilled: Boolean ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/model/MainScreenData.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallNfcPositionViewModelData.kt similarity index 60% rename from android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/model/MainScreenData.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallNfcPositionViewModelData.kt index 0534b989..28bff120 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/model/MainScreenData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallNfcPositionViewModelData.kt @@ -16,18 +16,22 @@ * */ -package de.gematik.ti.erp.app.mainscreen.ui.model +package de.gematik.ti.erp.app.cardwall.ui.model import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import de.gematik.ti.erp.app.mainscreen.ui.TaskIds +import de.gematik.ti.erp.app.cardwall.usecase.model.NfcPositionUseCaseData -object MainScreenData { +object CardWallNfcPositionViewModelData { @Immutable - data class RedeemState(val scannedTaskIds: TaskIds, val syncedTaskIds: TaskIds) { - @Stable - fun hasRedeemableTasks() = scannedTaskIds.isNotEmpty() || syncedTaskIds.isNotEmpty() - } - - val emptyRedeemState = RedeemState(TaskIds(emptyList()), TaskIds(emptyList())) + data class NfcPosition( + val nfcPosition: NfcPositionUseCaseData.NfcPosition = + NfcPositionUseCaseData.NfcPosition( + marketingName = "", + modelNames = emptyList(), + x0 = 0.5, + y0 = 0.3, + x1 = 0.5, + y1 = 0.3 + ) + ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/Navigation.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/Navigation.kt index def1f055..257fd0b5 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/Navigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/Navigation.kt @@ -25,26 +25,17 @@ object CardWallNavigation { object TroubleshootingPageB : Route("TroubleshootingPageB") object TroubleshootingPageC : Route("TroubleshootingPageC") object TroubleshootingNoSuccessPage : Route("TroubleshootingNoSuccessPage") - object TroubleshootingContactUs : Route("TroubleshootingContactUs") object ExternalAuthenticator : Route("ExternalAuthenticatorOverview") object Intro : Route("CardWallIntro") object MissingCapabilities : Route("MissingCapabilities") object CardAccessNumber : Route("CardWallCardAccessNumber") + object PersonalIdentificationNumber : Route("CardWallPersonalIdentificationNumber") object AuthenticationSelection : Route("CardWallAuthenticationSelection") + object AlternativeOption : Route("AlternativeOption") + object Authentication : Route("CardWallAuthentication") - object Switch : Route("CardWallSwitch") object InsuranceApp : Route("InsuranceApp") object OrderHealthCard : Route("OrderHealthCard") - object NoRoute : Route("") -} - -enum class CardWallSwitchNavigation { - INTRO, NO_ROUTE, INSURANCE_APP -} - -fun mapCardWallNavigation(nav: CardWallSwitchNavigation) = when (nav) { - CardWallSwitchNavigation.INTRO -> CardWallNavigation.Intro - CardWallSwitchNavigation.NO_ROUTE -> CardWallNavigation.NoRoute - CardWallSwitchNavigation.INSURANCE_APP -> CardWallNavigation.InsuranceApp + object UnlockEgk : Route("UnlockEgk") } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCase.kt index 0d36f0be..4c9dc7b6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCase.kt @@ -18,11 +18,43 @@ package de.gematik.ti.erp.app.cardwall.usecase +import android.nfc.TagLostException import android.os.Build +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.UserNotAuthenticatedException import androidx.annotation.RequiresApi import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.card.model.command.ResponseException import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardChannel +import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardSecureChannel +import de.gematik.ti.erp.app.card.model.command.ResponseStatus +import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.establishTrustedChannel +import de.gematik.ti.erp.app.card.model.exchange.retrieveCertificate +import de.gematik.ti.erp.app.card.model.exchange.signChallenge +import de.gematik.ti.erp.app.card.model.exchange.verifyPin +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.idp.usecase.AltAuthenticationCryptoException +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.profiles.repository.KVNRAlreadyAssignedException +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.consume +import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import org.json.JSONException +import org.json.JSONObject +import io.github.aakira.napier.Napier +import java.security.PublicKey @Stable sealed class AuthenticationState { @@ -52,6 +84,10 @@ sealed class AuthenticationState { // IDP failure states object IDPCommunicationFailed : AuthenticationState() + + object UserNotAuthenticated : AuthenticationState() + + object IDPCommunicationAltAuthNotSuccessful : AuthenticationState() object IDPCommunicationInvalidCertificate : AuthenticationState() object IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate : AuthenticationState() @@ -74,12 +110,18 @@ sealed class AuthenticationState { HealthCardPin1RetryLeft, HealthCardBlocked, IDPCommunicationFailed, + IDPCommunicationAltAuthNotSuccessful, IDPCommunicationInvalidCertificate, + IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate, + UserNotAuthenticated, is InsuranceIdentifierAlreadyExists, SecureElementCryptographyFailed -> true else -> false } + @Stable + fun isNotAuthenticatedFailure() = this == UserNotAuthenticated + @Stable fun isInProgress() = when (this) { @@ -99,21 +141,411 @@ sealed class AuthenticationState { fun isReady() = this == None } -interface AuthenticationUseCase { +/** + * Error codes returned by the IDP as an error JSON: `{ "gematik_code" : "..." }`. + */ +enum class IDPErrorCodes(val code: String) { + AltAuthNotSuccessful("2000"), + InvalidHealthCardCertificate("2020"), + InvalidOCSPResponseOfHealthCardCertificate("2021"), + Unknown("-"); + + companion object { + fun valueOfCode(code: String) = values().find { it.code == code } ?: Unknown + } +} + +class AuthenticationUseCase( + private val idpUseCase: IdpUseCase +) { + fun authenticateWithHealthCard( + profileId: ProfileIdentifier, + scope: IdpScope = IdpScope.Default, can: String, pin: String, cardChannel: Flow - ): Flow + ) = + authenticationFlowWithHealthCard( + profileId, + scope, + can, + pin, + cardChannel + ) + .onEach { Napier.d("AuthenticationState: $it") } + .catch { cause -> + Napier.e("Authentication error", cause) + handleException(cause).let { + emit(it) + } + } @RequiresApi(Build.VERSION_CODES.P) fun pairDeviceWithHealthCardAndSecureElement( + profileId: ProfileIdentifier, + can: String, + pin: String, + publicKeyOfSecureElementEntry: PublicKey, + aliasOfSecureElementEntry: ByteArray, + cardChannel: Flow + ): Flow { + return alternatePairingFlowWithSecureElement( + profileId = profileId, + can = can, + pin = pin, + sePublicKey = publicKeyOfSecureElementEntry, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + cardChannel = cardChannel + ) + .onEach { Napier.d("AuthenticationState: $it") } + .catch { cause -> + emit(handleException(cause)) + } + } + + fun authenticateWithSecureElement(profileId: ProfileIdentifier, scope: IdpScope) = + alternateAuthenticationFlowWithSecureElement(profileId, scope) + .onEach { Napier.d("AuthenticationState: $it") } + .catch { cause -> + Napier.e("Authentication error", cause) + emit(handleException(cause)) + } + + private fun authenticationFlowWithHealthCard( + profileId: ProfileIdentifier, + scope: IdpScope, + can: String, + pin: String, + cardChannel: Flow + ) = channelFlow { + send(AuthenticationState.AuthenticationFlowInitialized) + + cardChannel.first().use { nfcChannel -> + send(AuthenticationState.HealthCardCommunicationChannelReady) + + val healthCardCertificateChannel = Channel() + val signChannel = Channel() + val responseChannel = Channel() + + // + // + - IDP communication --------- + ------------- + -- + -------- + + // / ^ | ^ \ + // - start flow - + Health card cert Sign challenge + - end flow - + // \ | v | / + // + - Health card communication - + ------------- + -- + -------- + + // + + joinAll( + launch { + handleAsyncExceptions(AuthenticationExceptionKind.IDPCommunicationFailed) { + idpUseCase.authenticationFlowWithHealthCard( + profileId = profileId, + scope = scope, + cardAccessNumber = can, + { + healthCardCertificateChannel.consume { receive() } + }, + { + signChannel.send(it) + signChannel.close() + responseChannel.consume { receive() } + } + ) + send(AuthenticationState.IDPCommunicationFinished) + } + }, + launch { + handleAsyncExceptions(AuthenticationExceptionKind.HealthCardCommunicationFailed) { + healthCardCommunication( + nfcChannel, + healthCardCertificateChannel, + signChannel, + responseChannel, + can = can, + pin = pin + ) + } + } + ) + } + + send(AuthenticationState.AuthenticationFlowFinished) + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun alternatePairingFlowWithSecureElement( + profileId: ProfileIdentifier, can: String, pin: String, + sePublicKey: PublicKey, + aliasOfSecureElementEntry: ByteArray, cardChannel: Flow - ): Flow + ) = channelFlow { + send(AuthenticationState.AuthenticationFlowInitialized) + + cardChannel.first().use { nfcChannel -> + send(AuthenticationState.HealthCardCommunicationChannelReady) + + // FIXME + + val healthCardCertificateChannel = Channel() + val signChannel = Channel() + val responseChannel = Channel() + + // + // + - IDP communication --------- + ------------- + -- + --------- + -- + ------- + + // / ^ | ^ | ^ \ + // - start flow - + Health card cert Sign challenge Sign challenge + - end flow - + // \ | v | v | / + // + - Health card communication - + ------------- + -- + --------- + -- + ------- + + // + + var signingsLeft = 2 + joinAll( + launch { + handleAsyncExceptions(AuthenticationExceptionKind.IDPCommunicationFailed) { + idpUseCase.alternatePairingFlowWithSecureElement( + profileId = profileId, + cardAccessNumber = can, + publicKeyOfSecureElementEntry = sePublicKey, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + { + healthCardCertificateChannel.consume { receive() } + }, + { + signChannel.send(it) + responseChannel.receive().also { + signingsLeft-- + if (signingsLeft == 0) { + signChannel.close() + } + } + } + ) + send(AuthenticationState.IDPCommunicationFinished) + } + }, + launch { + handleAsyncExceptions(AuthenticationExceptionKind.HealthCardCommunicationFailed) { + healthCardCommunication( + nfcChannel, + healthCardCertificateChannel, + signChannel, + responseChannel, + can = can, + pin = pin + ) + } + } + ) + } + + send(AuthenticationState.AuthenticationFlowFinished) + } + + private inline fun handleAsyncExceptions(kind: AuthenticationExceptionKind, block: () -> Unit) { + try { + block() + } catch (expected: Exception) { + handleAsyncExceptions(expected, kind) + } + } + + private fun handleAsyncExceptions(e: Throwable, kind: AuthenticationExceptionKind) { + if (e.suppressed.isNotEmpty()) { + throw e.suppressed.first() + } + + Napier.e("Authentication error", e) + when (e) { + is CancellationException, + is AuthenticationException, + is ResponseException -> throw e + else -> { + when (e) { + is ApiCallException -> + handleApiCallException(e, kind) + is KVNRAlreadyAssignedException -> + throw AuthenticationException( + kind = AuthenticationExceptionKind.InsuranceIdentifierAlreadyAssigned, + cause = e + ) + else -> + throw AuthenticationException(kind) + } + } + } + } + + private fun handleApiCallException(e: ApiCallException, kind: AuthenticationExceptionKind) { + val code = e.response.errorBody() + ?.let { + try { + JSONObject(it.string())["gematik_code"] as? String + } catch (_: JSONException) { + null + } + } + ?.let { IDPErrorCodes.valueOfCode(it) } + + when (code) { + IDPErrorCodes.AltAuthNotSuccessful -> + throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationAltAuthNotSuccessful) + IDPErrorCodes.InvalidHealthCardCertificate -> + throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationInvalidCertificate) + IDPErrorCodes.InvalidOCSPResponseOfHealthCardCertificate -> + throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate) + else -> + throw AuthenticationException(kind) + } + } + + private fun alternateAuthenticationFlowWithSecureElement(profileId: ProfileIdentifier, scope: IdpScope) = + channelFlow { + send(AuthenticationState.AuthenticationFlowInitialized) + + try { + idpUseCase.alternateAuthenticationFlowWithSecureElement(profileId = profileId, scope = scope) + send(AuthenticationState.IDPCommunicationFinished) + } catch (e: Exception) { + Napier.e("Authentication error", e) + when (e) { + is KeyPermanentlyInvalidatedException, + is UserNotAuthenticatedException -> + throw AuthenticationException(AuthenticationExceptionKind.UserNotAuthenticated) + is AltAuthenticationCryptoException -> + throw AuthenticationException(AuthenticationExceptionKind.SecureElementFailure) + is ApiCallException -> + handleApiCallException(e, AuthenticationExceptionKind.IDPCommunicationFailed) + else -> + throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationFailed) + } + } + + send(AuthenticationState.AuthenticationFlowFinished) + } + + private suspend fun ProducerScope.healthCardCommunication( + channel: NfcCardChannel, + healthCardCertificateChannel: Channel, + signChannel: Channel, // `signChannel` is required to be closed by its caller + responseChannel: Channel, + can: String, + pin: String + ) { + val paceKey = channel.establishTrustedChannel(can) + + val secChannel = NfcCardSecureChannel( + channel.isExtendedLengthSupported, + channel.card, + paceKey + ) + send(AuthenticationState.HealthCardCommunicationTrustedChannelEstablished) + + healthCardCertificateChannel.send(secChannel.retrieveCertificate()) + send(AuthenticationState.HealthCardCommunicationCertificateLoaded) + + when (secChannel.verifyPin(pin)) { + ResponseStatus.SUCCESS -> { + signChannel.consumeEach { + responseChannel.send( + secChannel.signChallenge(it) + ) + } + } + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> + throw AuthenticationException(AuthenticationExceptionKind.HealthCardPin2RetriesLeft) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> + throw AuthenticationException(AuthenticationExceptionKind.HealthCardPin1RetryLeft) + else -> { + throw AuthenticationException(AuthenticationExceptionKind.HealthCardBlocked) + } + } + + send(AuthenticationState.HealthCardCommunicationFinished) + } + + private fun handleException(e: Throwable): AuthenticationState = + when (e) { + is CancellationException -> throw e + is AuthenticationException -> { + when (e.kind) { + AuthenticationExceptionKind.IDPCommunicationFailed -> + AuthenticationState.IDPCommunicationFailed + AuthenticationExceptionKind.IDPCommunicationAltAuthNotSuccessful -> + AuthenticationState.IDPCommunicationAltAuthNotSuccessful + AuthenticationExceptionKind.IDPCommunicationInvalidCertificate -> + AuthenticationState.IDPCommunicationInvalidCertificate + + AuthenticationExceptionKind.HealthCardBlocked -> + AuthenticationState.HealthCardBlocked + AuthenticationExceptionKind.HealthCardPin1RetryLeft -> + AuthenticationState.HealthCardPin1RetryLeft + AuthenticationExceptionKind.HealthCardPin2RetriesLeft -> + AuthenticationState.HealthCardPin2RetriesLeft + AuthenticationExceptionKind.HealthCardCommunicationFailed -> + AuthenticationState.HealthCardCommunicationInterrupted + AuthenticationExceptionKind.SecureElementFailure -> + AuthenticationState.SecureElementCryptographyFailed + AuthenticationExceptionKind.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate -> + AuthenticationState.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate + + AuthenticationExceptionKind.InsuranceIdentifierAlreadyAssigned -> { + val alreadyAssignedException = (e.cause!! as KVNRAlreadyAssignedException) + AuthenticationState.InsuranceIdentifierAlreadyExists( + inActiveProfile = alreadyAssignedException.isActiveProfile, + profileName = alreadyAssignedException.inProfile, + insuranceIdentifier = alreadyAssignedException.insuranceIdentifier + ) + } + AuthenticationExceptionKind.UserNotAuthenticated -> AuthenticationState.UserNotAuthenticated + } + } + is ResponseException -> { + when (e.responseStatus) { + ResponseStatus.AUTHENTICATION_FAILURE -> AuthenticationState.HealthCardCardAccessNumberWrong + else -> AuthenticationState.HealthCardCommunicationInterrupted + } + } + is TagLostException, is IOException -> { + Napier.e("IO Exception / NFC TAG was lost", e) + AuthenticationState.HealthCardCommunicationInterrupted + } + else -> { + Napier.e("Unknown exception", e) + // soft fail + AuthenticationState.HealthCardCommunicationInterrupted + } + } +} + +private enum class AuthenticationExceptionKind { + IDPCommunicationFailed, + IDPCommunicationAltAuthNotSuccessful, + IDPCommunicationInvalidCertificate, + IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate, + + HealthCardBlocked, + HealthCardPin1RetryLeft, + HealthCardPin2RetriesLeft, + HealthCardCommunicationFailed, + + InsuranceIdentifierAlreadyAssigned, + + SecureElementFailure, + UserNotAuthenticated, +} + +private class AuthenticationException : IllegalStateException { + var kind: AuthenticationExceptionKind + private set - fun authenticateWithSecureElement(): Flow + constructor(kind: AuthenticationExceptionKind, cause: Throwable) : super(kind.name, cause) { + this.kind = kind + } - suspend fun isCanAvailable(): Boolean + constructor(kind: AuthenticationExceptionKind) : super(kind.name) { + this.kind = kind + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseDelegate.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseDelegate.kt deleted file mode 100644 index e24d4340..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseDelegate.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.usecase - -import android.os.Build -import androidx.annotation.RequiresApi -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardChannel -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class AuthenticationUseCaseDelegate @Inject constructor( - private val demoDelegate: AuthenticationUseCaseDemo, - private val productionDelegate: AuthenticationUseCaseProduction, - private val demoUseCase: DemoUseCase -) : AuthenticationUseCase { - private val delegate: AuthenticationUseCase - get() = if (demoUseCase.isDemoModeActive) demoDelegate else productionDelegate - - override fun authenticateWithHealthCard( - can: String, - pin: String, - cardChannel: Flow - ): Flow = - delegate.authenticateWithHealthCard(can, pin, cardChannel) - - @RequiresApi(Build.VERSION_CODES.P) - override fun pairDeviceWithHealthCardAndSecureElement( - can: String, - pin: String, - cardChannel: Flow - ): Flow = - delegate.pairDeviceWithHealthCardAndSecureElement(can, pin, cardChannel) - - override fun authenticateWithSecureElement(): Flow = - delegate.authenticateWithSecureElement() - - override suspend fun isCanAvailable(): Boolean = - delegate.isCanAvailable() -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseDemo.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseDemo.kt deleted file mode 100644 index 6fc02e56..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseDemo.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.usecase - -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardChannel -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject - -private const val DEMO_TIMEOUT = 700L - -class AuthenticationUseCaseDemo @Inject constructor( - private val demoUseCase: DemoUseCase -) : AuthenticationUseCase { - - override fun authenticateWithHealthCard( - can: String, - pin: String, - cardChannel: Flow - ): Flow = flow { - val states = listOf( - AuthenticationState.AuthenticationFlowInitialized, - AuthenticationState.HealthCardCommunicationChannelReady, - AuthenticationState.HealthCardCommunicationTrustedChannelEstablished, - AuthenticationState.HealthCardCommunicationCertificateLoaded, - AuthenticationState.HealthCardCommunicationFinished, - AuthenticationState.IDPCommunicationFinished, - AuthenticationState.AuthenticationFlowFinished - ) - - states.forEach { - val stepTimeout = if (it == AuthenticationState.HealthCardCommunicationChannelReady) { - DEMO_TIMEOUT * 5L - } else { - DEMO_TIMEOUT - } - emit(it) - delay(stepTimeout) - } - }.onEach { - if (it.isFinal()) { - demoUseCase.authTokenReceived.value = true - } - }.catch { - emit(AuthenticationState.HealthCardCommunicationInterrupted) - } - - override fun pairDeviceWithHealthCardAndSecureElement( - can: String, - pin: String, - cardChannel: Flow - ): Flow = authenticateWithHealthCard(can, pin, cardChannel) - - override fun authenticateWithSecureElement(): Flow = - authenticateWithHealthCard("", "", emptyFlow()) - - override suspend fun isCanAvailable(): Boolean = false -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseProduction.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseProduction.kt deleted file mode 100644 index 541bca90..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseProduction.kt +++ /dev/null @@ -1,472 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.usecase - -import android.nfc.TagLostException -import android.os.Build -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import androidx.annotation.RequiresApi -import de.gematik.ti.erp.app.api.ApiCallException -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseException -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseStatus -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.establishTrustedChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.retrieveCertificate -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.signChallenge -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.verifyPin -import de.gematik.ti.erp.app.idp.usecase.AltAuthenticationCryptoException -import de.gematik.ti.erp.app.idp.usecase.IdpUseCase -import de.gematik.ti.erp.app.profiles.repository.KVNRAlreadyAssignedException -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.secureRandomInstance -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ProducerScope -import kotlinx.coroutines.channels.consume -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import org.json.JSONObject -import timber.log.Timber -import java.io.IOException -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.spec.ECGenParameterSpec -import javax.inject.Inject - -/** - * Error codes returned by the IDP as an error JSON: `{ "gematik_code" : "..." }`. - */ -enum class IDPErrorCodes(val code: String) { - InvalidHealthCardCertificate("2020"), - InvalidOCSPResponseOfHealthCardCertificate("2021"), - Unknown("-"); - - companion object { - fun valueOfCode(code: String) = values().find { it.code == code } ?: Unknown - } -} - -class AuthenticationUseCaseProduction @Inject constructor( - private val idpUseCase: IdpUseCase, - private val profilesUseCase: ProfilesUseCase -) : AuthenticationUseCase { - - override fun authenticateWithHealthCard( - can: String, - pin: String, - cardChannel: Flow - ) = - authenticationFlowWithHealthCard( - can, - pin, - cardChannel - ) - .onEach { Timber.d("AuthenticationState: $it") } - .catch { cause -> - Timber.e(cause) - handleException(cause).let { - emit(it) - } - } - - @RequiresApi(Build.VERSION_CODES.P) - override fun pairDeviceWithHealthCardAndSecureElement( - can: String, - pin: String, - cardChannel: Flow - ): Flow { - val aliasOfSecureElementEntry = ByteArray(32).apply { - secureRandomInstance().nextBytes(this) - } - - return alternatePairingFlowWithSecureElement( - can, - pin, - aliasOfSecureElementEntry, - cardChannel - ) - .onEach { Timber.d("AuthenticationState: $it") } - .catch { cause -> - handleException(cause).let { - emit(it) - - try { - KeyStore.getInstance("AndroidKeyStore") - .apply { load(null) } - .deleteEntry(aliasOfSecureElementEntry.decodeToString()) - } catch (e: Exception) { - Timber.e(e, "Couldn't remove key from keystore on failure; expected to happen.") - } - } - } - } - - override fun authenticateWithSecureElement() = - profilesUseCase.activeProfileName().flatMapLatest { activeProfileName -> - alternateAuthenticationFlowWithSecureElement(activeProfileName) - .onEach { Timber.d("AuthenticationState: $it") } - .catch { cause -> - emit(handleException(cause)) - } - } - - override suspend fun isCanAvailable(): Boolean = idpUseCase.isCanAvailable() - - @OptIn(ExperimentalCoroutinesApi::class) - private fun authenticationFlowWithHealthCard( - can: String, - pin: String, - cardChannel: Flow - ) = channelFlow { - send(AuthenticationState.AuthenticationFlowInitialized) - - cardChannel.first().use { nfcChannel -> - send(AuthenticationState.HealthCardCommunicationChannelReady) - - val healthCardCertificateChannel = Channel() - val signChannel = Channel() - val responseChannel = Channel() - - // - // + - IDP communication --------- + ------------- + -- + -------- + - // / ^ | ^ \ - // - start flow - + Health card cert Sign challenge + - end flow - - // \ | v | / - // + - Health card communication - + ------------- + -- + -------- + - // - - joinAll( - launch { - try { - idpUseCase.authenticationFlowWithHealthCard( - { - healthCardCertificateChannel.consume { receive() } - }, - { - signChannel.send(it) - signChannel.close() - responseChannel.consume { receive() } - } - ) - send(AuthenticationState.IDPCommunicationFinished) - } catch (e: Exception) { - handleAsyncExceptions(e, AuthenticationExceptionKind.IDPCommunicationFailed) - } - }, - launch { - try { - healthCardCommunication( - nfcChannel, - healthCardCertificateChannel, - signChannel, - responseChannel, - can = can, - pin = pin - ) - } catch (e: Exception) { - handleAsyncExceptions(e, AuthenticationExceptionKind.HealthCardCommunicationFailed) - } - } - ) - } - - send(AuthenticationState.AuthenticationFlowFinished) - } - - @RequiresApi(Build.VERSION_CODES.P) - @OptIn(ExperimentalCoroutinesApi::class) - @Suppress("Deprecation") - private fun alternatePairingFlowWithSecureElement( - can: String, - pin: String, - aliasOfSecureElementEntry: ByteArray, - cardChannel: Flow - ) = channelFlow { - send(AuthenticationState.AuthenticationFlowInitialized) - - cardChannel.first().use { nfcChannel -> - send(AuthenticationState.HealthCardCommunicationChannelReady) - - val sePublicKey = try { - val keyPairGenerator = KeyPairGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_EC, - "AndroidKeyStore" - ) - - val parameterSpec = KeyGenParameterSpec.Builder( - aliasOfSecureElementEntry.decodeToString(), - KeyProperties.PURPOSE_SIGN - ).apply { - setInvalidatedByBiometricEnrollment(true) - setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters(60, KeyProperties.AUTH_BIOMETRIC_STRONG) - } else { - setUserAuthenticationValidityDurationSeconds(60) - } - setIsStrongBoxBacked(true) - setDigests(KeyProperties.DIGEST_SHA256) - - setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) - }.build() - - keyPairGenerator.initialize(parameterSpec) - val keyPair = keyPairGenerator.generateKeyPair() - - keyPair.public - } catch (e: Exception) { - throw AuthenticationException(AuthenticationExceptionKind.SecureElementFailure) - } - - val healthCardCertificateChannel = Channel() - val signChannel = Channel() - val responseChannel = Channel() - - // - // + - IDP communication --------- + ------------- + -- + --------- + -- + ------- + - // / ^ | ^ | ^ \ - // - start flow - + Health card cert Sign challenge Sign challenge + - end flow - - // \ | v | v | / - // + - Health card communication - + ------------- + -- + --------- + -- + ------- + - // - - var signingsLeft = 2 - joinAll( - launch { - try { - idpUseCase.alternatePairingFlowWithSecureElement( - publicKeyOfSecureElementEntry = sePublicKey, - aliasOfSecureElementEntry = aliasOfSecureElementEntry, - { - healthCardCertificateChannel.consume { receive() } - }, - { - signChannel.send(it) - responseChannel.receive().also { - signingsLeft-- - if (signingsLeft == 0) { - signChannel.close() - } - } - } - ) - send(AuthenticationState.IDPCommunicationFinished) - } catch (e: Exception) { - handleAsyncExceptions(e, AuthenticationExceptionKind.IDPCommunicationFailed) - } - }, - launch { - try { - healthCardCommunication( - nfcChannel, - healthCardCertificateChannel, - signChannel, - responseChannel, - can = can, - pin = pin - ) - } catch (e: Exception) { - handleAsyncExceptions(e, AuthenticationExceptionKind.HealthCardCommunicationFailed) - } - } - ) - } - - send(AuthenticationState.AuthenticationFlowFinished) - } - - private fun handleAsyncExceptions(e: Throwable, kind: AuthenticationExceptionKind) { - if (e.suppressed.isNotEmpty()) { - throw e.suppressed.first() - } else { - Timber.e(e) - when (e) { - is CancellationException, - is AuthenticationException, - is ResponseException -> throw e - else -> { - if (e is ApiCallException) { - val code = e.response.errorBody() - ?.let { JSONObject(it.string())["gematik_code"] as? String } - ?.let { IDPErrorCodes.valueOfCode(it) } - - when (code) { - IDPErrorCodes.InvalidHealthCardCertificate -> - throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationInvalidCertificate) - IDPErrorCodes.InvalidOCSPResponseOfHealthCardCertificate -> - throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate) - else -> - throw AuthenticationException(kind) - } - } else if (e is KVNRAlreadyAssignedException) { - throw AuthenticationException(AuthenticationExceptionKind.InsuranceIdentifierAlreadyAssigned, e) - } else { - throw AuthenticationException(kind) - } - } - } - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - private fun alternateAuthenticationFlowWithSecureElement(profileName: String) = channelFlow { - send(AuthenticationState.AuthenticationFlowInitialized) - - try { - idpUseCase.alternateAuthenticationFlowWithSecureElement(profileName) - send(AuthenticationState.IDPCommunicationFinished) - } catch (e: Exception) { - when (e) { - is AltAuthenticationCryptoException -> throw AuthenticationException(AuthenticationExceptionKind.SecureElementFailure) - else -> throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationFailed) - } - } - - send(AuthenticationState.AuthenticationFlowFinished) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private suspend fun ProducerScope.healthCardCommunication( - channel: NfcCardChannel, - healthCardCertificateChannel: Channel, - signChannel: Channel, // `signChannel` is required to be closed by its caller - responseChannel: Channel, - can: String, - pin: String - ) { - val paceKey = channel.establishTrustedChannel(can) - - val secChannel = NfcCardSecureChannel( - channel.isExtendedLengthSupported, - channel.card, - paceKey - ) - send(AuthenticationState.HealthCardCommunicationTrustedChannelEstablished) - - healthCardCertificateChannel.send(secChannel.retrieveCertificate()) - send(AuthenticationState.HealthCardCommunicationCertificateLoaded) - - when (secChannel.verifyPin(pin)) { - ResponseStatus.SUCCESS -> { - signChannel.consumeEach { - responseChannel.send( - secChannel.signChallenge(it) - ) - } - } - ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> - throw AuthenticationException(AuthenticationExceptionKind.HealthCardPin2RetriesLeft) - ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> - throw AuthenticationException(AuthenticationExceptionKind.HealthCardPin1RetryLeft) - else -> { - throw AuthenticationException(AuthenticationExceptionKind.HealthCardBlocked) - } - } - - send(AuthenticationState.HealthCardCommunicationFinished) - } - - private fun handleException(e: Throwable): AuthenticationState = - when (e) { - is CancellationException -> throw e - is AuthenticationException -> { - when (e.kind) { - AuthenticationExceptionKind.IDPCommunicationFailed -> - AuthenticationState.IDPCommunicationFailed - AuthenticationExceptionKind.IDPCommunicationInvalidCertificate -> - AuthenticationState.IDPCommunicationInvalidCertificate - - AuthenticationExceptionKind.HealthCardBlocked -> - AuthenticationState.HealthCardBlocked - AuthenticationExceptionKind.HealthCardPin1RetryLeft -> - AuthenticationState.HealthCardPin1RetryLeft - AuthenticationExceptionKind.HealthCardPin2RetriesLeft -> - AuthenticationState.HealthCardPin2RetriesLeft - AuthenticationExceptionKind.HealthCardCommunicationFailed -> - AuthenticationState.HealthCardCommunicationInterrupted - AuthenticationExceptionKind.SecureElementFailure -> - AuthenticationState.SecureElementCryptographyFailed - AuthenticationExceptionKind.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate -> - AuthenticationState.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate - - AuthenticationExceptionKind.InsuranceIdentifierAlreadyAssigned -> { - val alreadyAssignedException = (e.cause !!as KVNRAlreadyAssignedException) - AuthenticationState.InsuranceIdentifierAlreadyExists( - inActiveProfile = alreadyAssignedException.isActiveProfile, - profileName = alreadyAssignedException.inProfile, - insuranceIdentifier = alreadyAssignedException.insuranceIdentifier - ) - } - } - } - is ResponseException -> { - when (e.responseStatus) { - ResponseStatus.AUTHENTICATION_FAILURE -> AuthenticationState.HealthCardCardAccessNumberWrong - else -> AuthenticationState.HealthCardCommunicationInterrupted - } - } - is TagLostException, is IOException -> { - Timber.e(e, "IO Exception / NFC TAG was lost") - AuthenticationState.HealthCardCommunicationInterrupted - } - else -> { - Timber.e(e, "Unknown exception") - // soft fail - AuthenticationState.HealthCardCommunicationInterrupted - } - } -} - -private enum class AuthenticationExceptionKind { - IDPCommunicationFailed, - IDPCommunicationInvalidCertificate, - IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate, - - HealthCardBlocked, - HealthCardPin1RetryLeft, - HealthCardPin2RetriesLeft, - HealthCardCommunicationFailed, - - InsuranceIdentifierAlreadyAssigned, - - SecureElementFailure, -} - -private class AuthenticationException : IllegalStateException { - var kind: AuthenticationExceptionKind - private set - - constructor(kind: AuthenticationExceptionKind, cause: Throwable) : super(kind.name, cause) { - this.kind = kind - } - - constructor(kind: AuthenticationExceptionKind) : super(kind.name) { - this.kind = kind - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallLoadNfcPositionUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallLoadNfcPositionUseCase.kt new file mode 100644 index 00000000..8c36f709 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallLoadNfcPositionUseCase.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.usecase + +import android.content.Context +import android.os.Build +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.usecase.model.NfcPositionUseCaseData +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import java.io.InputStream + +class CardWallLoadNfcPositionUseCase( + private val context: Context +) { + private val nfcPositions: List by lazy { + loadNfcPositionsFromJSON( + context.resources.openRawResourceFd(R.raw.nfc_positions).createInputStream() + ).sortedBy { it.marketingName.lowercase() } + } + + fun findNfcPositionForPhone() = nfcPositions.find { it.modelNames.contains(Build.MODEL) } +} + +private fun loadNfcPositionsFromJSON(jsonInput: InputStream): List = + Json.decodeFromString(jsonInput.bufferedReader().readText()) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCase.kt index af8501ba..b9d9c762 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCase.kt @@ -18,19 +18,37 @@ package de.gematik.ti.erp.app.cardwall.usecase -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData +import android.content.Context +import android.nfc.NfcAdapter +import de.gematik.ti.erp.app.app +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.idp.repository.IdpRepository +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.settings.repository.CardWallRepository import kotlinx.coroutines.flow.Flow -interface CardWallUseCase { - var cardWallIntroIsAccepted: Boolean - +open class CardWallUseCase( + private val idpRepository: IdpRepository, + private val cardWallRepository: CardWallRepository +) { var deviceHasNFCAndAndroidMOrHigher: Boolean - val deviceHasNFCEnabled: Boolean + get() = app().deviceHasNFC() || cardWallRepository.hasFakeNFCEnabled + set(value) { + cardWallRepository.hasFakeNFCEnabled = value + } + + val deviceHasNFCEnabled + get() = app().nfcEnabled() - suspend fun cardAccessNumberWasSaved(): Flow + fun authenticationData(profileId: ProfileIdentifier): Flow = + idpRepository.authenticationData(profileId) +} - suspend fun getAuthenticationMethod(profileName: String): CardWallData.AuthenticationMethod +fun Context.deviceHasNFC(): Boolean = + this.packageManager.hasSystemFeature("android.hardware.nfc") - suspend fun setCardAccessNumber(can: String?) - fun cardAccessNumber(): Flow +private fun Context.nfcEnabled(): Boolean = if (this.deviceHasNFC()) { + NfcAdapter.getDefaultAdapter(this).isEnabled +} else { + false } diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseDelegate.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseDelegate.kt deleted file mode 100644 index badfc7b3..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseDelegate.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.usecase - -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow - -class CardWallUseCaseDelegate @Inject constructor( - private val demoDelegate: CardWallUseCaseDemo, - private val productionDelegate: CardWallUseCaseProduction, - private val demoUseCase: DemoUseCase -) : CardWallUseCase { - private val delegate: CardWallUseCase - get() = if (demoUseCase.isDemoModeActive) demoDelegate else productionDelegate - - override var cardWallIntroIsAccepted: Boolean by delegate::cardWallIntroIsAccepted - override var deviceHasNFCAndAndroidMOrHigher: Boolean by delegate::deviceHasNFCAndAndroidMOrHigher - override val deviceHasNFCEnabled: Boolean by delegate::deviceHasNFCEnabled - - override suspend fun cardAccessNumberWasSaved(): Flow = - delegate.cardAccessNumberWasSaved() - - override suspend fun getAuthenticationMethod(profileName: String): CardWallData.AuthenticationMethod = - delegate.getAuthenticationMethod(profileName) - - override suspend fun setCardAccessNumber(can: String?) { - delegate.setCardAccessNumber(can) - } - - override fun cardAccessNumber(): Flow = - delegate.cardAccessNumber() -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseDemo.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseDemo.kt deleted file mode 100644 index c48d07b8..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseDemo.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.usecase - -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -class CardWallUseCaseDemo @Inject constructor() : CardWallUseCase { - - override var cardWallIntroIsAccepted: Boolean = false - - override var deviceHasNFCAndAndroidMOrHigher = true - - override val deviceHasNFCEnabled = true - - override suspend fun cardAccessNumberWasSaved(): Flow = flow { emit(false) } - - override suspend fun getAuthenticationMethod(profileName: String): CardWallData.AuthenticationMethod = - CardWallData.AuthenticationMethod.None - - override suspend fun setCardAccessNumber(can: String?) {} - - override fun cardAccessNumber() = flow { - emit(null) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseProduction.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseProduction.kt deleted file mode 100644 index 50c32202..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/CardWallUseCaseProduction.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.cardwall.usecase - -import android.content.Context -import android.nfc.NfcAdapter -import android.os.Build -import de.gematik.ti.erp.app.app -import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData -import de.gematik.ti.erp.app.db.entities.IdpAuthenticationDataEntity -import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.settings.repository.CardWallRepository -import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map - -@ExperimentalCoroutinesApi -open class CardWallUseCaseProduction @Inject constructor( - private val idpRepository: IdpRepository, - private val cardWallRepository: CardWallRepository, - private val profilesUseCase: ProfilesUseCase, -) : CardWallUseCase { - - override var cardWallIntroIsAccepted - set(v) { - cardWallRepository.introAccepted = v - } - get() = cardWallRepository.introAccepted - - override suspend fun cardAccessNumberWasSaved(): Flow { - return profilesUseCase.activeProfileName().flatMapLatest { - idpRepository.cardAccessNumber(it) - }.map { - it?.isNotBlank() == true - } - } - - override suspend fun setCardAccessNumber(can: String?) { - val activeProfileName = profilesUseCase.activeProfileName().first() - idpRepository.setCardAccessNumber(activeProfileName, can) - } - - override fun cardAccessNumber(): Flow { - return profilesUseCase.activeProfileName().flatMapLatest { - idpRepository.cardAccessNumber(it) - } - } - - override var deviceHasNFCAndAndroidMOrHigher: Boolean - get() = app().deviceHasNFCAndAndroidMOrHigher() || cardWallRepository.hasFakeNFCEnabled - set(value) { - cardWallRepository.hasFakeNFCEnabled = value - } - - override val deviceHasNFCEnabled - get() = app().nfcEnabled() - - override suspend fun getAuthenticationMethod(profileName: String): CardWallData.AuthenticationMethod = - when (idpRepository.getSingleSignOnTokenScope(profileName).first()) { - IdpAuthenticationDataEntity.SingleSignOnTokenScope.Default -> CardWallData.AuthenticationMethod.HealthCard - IdpAuthenticationDataEntity.SingleSignOnTokenScope.AlternateAuthentication -> CardWallData.AuthenticationMethod.Alternative - null -> CardWallData.AuthenticationMethod.None - } -} - -private fun Context.deviceHasNFCAndAndroidMOrHigher(): Boolean { - val hasNfc = this.packageManager.hasSystemFeature("android.hardware.nfc") - val isAndroidMOrHigher = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - return hasNfc && isAndroidMOrHigher -} - -private fun Context.nfcEnabled(): Boolean = if (this.deviceHasNFCAndAndroidMOrHigher()) { - NfcAdapter.getDefaultAdapter(this).isEnabled -} else { - false -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/MiniCardWallUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/MiniCardWallUseCase.kt new file mode 100644 index 00000000..8ed8b626 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/MiniCardWallUseCase.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.cardwall.usecase + +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.idp.repository.IdpRepository +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class MiniCardWallUseCase( + private val idpRepository: IdpRepository, + private val profilesUseCase: ProfilesUseCase +) { + fun authenticationData(profileId: ProfileIdentifier): Flow = + idpRepository.authenticationData(profileId) + + fun profileData(profileId: ProfileIdentifier): Flow = + profilesUseCase.profiles.map { profiles -> + requireNotNull(profiles.find { it.id == profileId }) { "Profile `$profileId` missing!" } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/TruststoreEntity.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/model/NfcPositionUseCaseData.kt similarity index 63% rename from android/src/main/java/de/gematik/ti/erp/app/db/entities/TruststoreEntity.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/model/NfcPositionUseCaseData.kt index 3787c52c..60c3e1d2 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/TruststoreEntity.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/model/NfcPositionUseCaseData.kt @@ -16,18 +16,20 @@ * */ -package de.gematik.ti.erp.app.db.entities +package de.gematik.ti.erp.app.cardwall.usecase.model -import androidx.room.Entity -import androidx.room.PrimaryKey -import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList -import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable -@Entity(tableName = "truststore") -data class TruststoreEntity( - val certList: UntrustedCertList, - val ocspList: UntrustedOCSPList, -) { - @PrimaryKey - var id = 1 +object NfcPositionUseCaseData { + @Immutable + @Serializable + data class NfcPosition( + val marketingName: String, + val modelNames: List, + val x0: Double, + val y0: Double, + val x1: Double, + val y1: Double + ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/common/usecase/HintUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/common/usecase/HintUseCase.kt deleted file mode 100644 index 8a6a9d10..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/common/usecase/HintUseCase.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.common.usecase - -import android.content.SharedPreferences -import androidx.core.content.edit -import de.gematik.ti.erp.app.common.usecase.model.CancellableHint -import de.gematik.ti.erp.app.common.usecase.model.Hint -import de.gematik.ti.erp.app.di.ApplicationPreferences -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import javax.inject.Inject - -private val knownCancellableHints = CancellableHint::class.sealedSubclasses.mapNotNull { it.objectInstance }.toSet() -private const val preferencePrefix = "CancellableHint_" - -class HintUseCase @Inject constructor( - @ApplicationPreferences - private val preferences: SharedPreferences -) { - private val _cancelledHints = MutableStateFlow(setOf()) - val cancelledHints: Flow> - get() = _cancelledHints - - private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key.startsWith(preferencePrefix)) { - val k = key.removePrefix(preferencePrefix) - val hint = requireNotNull( - knownCancellableHints.find { - it.id == k - } - ) - - if (isHintCanceled(hint)) { - _cancelledHints.value += hint - } else { - _cancelledHints.value -= hint - } - } - } - - init { - _cancelledHints.value = knownCancellableHints - .filter { - isHintCanceled(it) - } - .toSet() - - preferences.registerOnSharedPreferenceChangeListener(listener) - } - - fun isHintCanceled(hint: CancellableHint): Boolean { - require(knownCancellableHints.contains(hint)) - return preferences.getBoolean(hint.prefKey(), false) - } - - fun cancelHint(hint: CancellableHint) { - require(knownCancellableHints.contains(hint)) - preferences.edit { - putBoolean(hint.prefKey(), true) - } - } - - fun resetAllHints() { - preferences.edit { - knownCancellableHints.forEach { - putBoolean(it.prefKey(), false) - } - } - } - - fun resetHint(hint: CancellableHint) { - require(knownCancellableHints.contains(hint)) - preferences.edit { - putBoolean(hint.prefKey(), false) - } - } - - private fun CancellableHint.prefKey() = preferencePrefix + this.id -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/common/usecase/model/Hint.kt b/android/src/main/java/de/gematik/ti/erp/app/common/usecase/model/Hint.kt deleted file mode 100644 index 68bceefe..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/common/usecase/model/Hint.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.common.usecase.model - -sealed class Hint - -sealed class CancellableHint( - val id: String -) : Hint() - -// TODO: starting with Kotlin 1.5 subclasses of sealed classes are only restricted to its package -// TODO: move hints to separate files - -object PrescriptionScreenHintDemoModeActivated : CancellableHint(id = "PrescriptionScreenHintDemoModeActivated") -object PrescriptionScreenHintTryDemoMode : CancellableHint(id = "PrescriptionScreenHintTryDemoMode") -object PrescriptionScreenHintDefineSecurity : Hint() -data class PrescriptionScreenHintNewPrescriptions(val count: Int) : Hint() - -object PharmacyScreenHintEnableLocation : CancellableHint(id = "PharmacyScreenHintEnableLocation") diff --git a/android/src/main/java/de/gematik/ti/erp/app/core/IntentHandler.kt b/android/src/main/java/de/gematik/ti/erp/app/core/IntentHandler.kt new file mode 100644 index 00000000..3ddb02d5 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/core/IntentHandler.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.core + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import io.github.aakira.napier.Napier +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import java.net.URI + +const val FastTrackBaseUri = "https://das-e-rezept-fuer-deutschland.de/extauth" +const val ShareBaseUri = "https://das-e-rezept-fuer-deutschland.de/prescription" + +@Stable +class IntentHandler(private val context: Context) { + private val extAuthChannel = Channel(Channel.CONFLATED) + private val shareChannel = Channel(Channel.CONFLATED) + + val extAuthIntent = extAuthChannel.receiveAsFlow() + + val shareIntent = shareChannel.receiveAsFlow() + + suspend fun propagateIntent(intent: Intent) { + intent.data?.let { + val value = it.toString() + Napier.d("Received new intent: $value") + + when { + value.startsWith(FastTrackBaseUri) -> + extAuthChannel.send(value) + value.startsWith(ShareBaseUri) -> + shareChannel.send(value) + } + } + } + + fun startFastTrackApp(redirect: URI) { + clear() // clear possible cached values + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(redirect.toString()))) + } + + private fun clear() { + extAuthChannel.tryReceive() + shareChannel.tryReceive() + } +} + +val LocalIntentHandler = + staticCompositionLocalOf { error("No intent handler provided!") } diff --git a/android/src/main/java/de/gematik/ti/erp/app/core/MainComposable.kt b/android/src/main/java/de/gematik/ti/erp/app/core/MainComposable.kt index 8e49a82b..f0da8e7b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/core/MainComposable.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/core/MainComposable.kt @@ -51,45 +51,46 @@ import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.toSize -import androidx.hilt.navigation.compose.hiltViewModel -import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.systemuicontroller.rememberSystemUiController +import de.gematik.ti.erp.app.cardwall.mini.ui.Authenticator import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.tracking.Tracker +import de.gematik.ti.erp.app.analytics.Analytics import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel import kotlin.math.max import kotlin.math.min +val LocalAuthenticator = + staticCompositionLocalOf { error("No authenticator provided!") } + val LocalActivity = staticCompositionLocalOf { error("No activity provided!") } -val LocalTracker = - staticCompositionLocalOf { error("No tracker provided!") } +val LocalAnalytics = + staticCompositionLocalOf { error("No analytics provided!") } @Composable fun MainContent( - mainViewModel: MainViewModel = hiltViewModel(), content: @Composable (mainViewModel: MainViewModel) -> Unit ) { + val mainViewModel by rememberViewModel() val zoomEnabled by mainViewModel.zoomEnabled.collectAsState(false) - val systemUiController = rememberSystemUiController() - val useDarkIcons = MaterialTheme.colors.isLight - SideEffect { - systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = useDarkIcons) - } + AppTheme { + val systemUiController = rememberSystemUiController() + val useDarkIcons = MaterialTheme.colors.isLight + SideEffect { + systemUiController.setSystemBarsColor(Color.Transparent, darkIcons = useDarkIcons) + } - ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - AppTheme { - Box( - modifier = Modifier.zoomable(enabled = zoomEnabled) - ) { - content(mainViewModel) - } + Box( + modifier = Modifier.zoomable(enabled = zoomEnabled) + ) { + content(mainViewModel) } } } @@ -125,7 +126,7 @@ fun Modifier.zoomable( scaleX = scale.value, scaleY = scale.value, translationX = offset.value.x, - translationY = offset.value.y, + translationY = offset.value.y ) .pointerInput(enabled) { if (!enabled) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/core/MainViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/core/MainViewModel.kt index 817a356c..30a4886c 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/core/MainViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/core/MainViewModel.kt @@ -18,33 +18,29 @@ package de.gematik.ti.erp.app.core -import android.net.Uri +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.attestation.usecase.SafetynetUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject -import java.time.LocalDate +import kotlinx.coroutines.runBlocking -@HiltViewModel -class MainViewModel @Inject constructor( - private val settingsUseCase: SettingsUseCase, +class MainViewModel( safetynetUseCase: SafetynetUseCase, - private val profilesUseCase: ProfilesUseCase, -) : BaseViewModel() { - var externalAuthorizationUri: Uri? = null - val zoomEnabled by settingsUseCase::zoomEnabled - val authenticationMethod by settingsUseCase::authenticationMethod - var isNewUser by settingsUseCase::isNewUser + private val settingsUseCase: SettingsUseCase +) : ViewModel() { + val zoomEnabled = settingsUseCase.general.map { it.zoomEnabled } + val authenticationMethod = settingsUseCase.authenticationMode + var showOnboarding = runBlocking { settingsUseCase.showOnboarding.first() } + var showWelcomeDrawer = runBlocking { settingsUseCase.showWelcomeDrawer } private var insecureDevicePromptShown = false val showInsecureDevicePrompt = settingsUseCase .showInsecureDevicePrompt .map { - if (isNewUser) { + if (showOnboarding) { false } else if (!insecureDevicePromptShown) { insecureDevicePromptShown = true @@ -68,28 +64,22 @@ class MainViewModel @Inject constructor( } } - val showProfileSetupPrompt = - profilesUseCase.isProfileSetupCompleted() - .map { ! it } - fun onAcceptInsecureDevice() { viewModelScope.launch { settingsUseCase.acceptInsecureDevice() } } - fun overwriteDefaultProfile(profileName: String) { + fun acceptUpdatedDataTerms() { viewModelScope.launch { - profilesUseCase.overwriteDefaultProfileName(profileName) + settingsUseCase.acceptUpdatedDataTerms() } } - fun acceptUpdatedDataTerms(date: LocalDate) { - viewModelScope.launch { - settingsUseCase.updatedDataTermsAccepted(date) - } + suspend fun welcomeDrawerShown() { + settingsUseCase.welcomeDrawerShown() } - fun dataProtectionVersionAccepted() = - settingsUseCase.dataProtectionVersionAccepted() + fun dataProtectionVersionAcceptedOn() = + settingsUseCase.general.map { it.dataProtectionVersionAcceptedOn } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/AppDatabase.kt b/android/src/main/java/de/gematik/ti/erp/app/db/AppDatabase.kt deleted file mode 100644 index b603be3e..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/AppDatabase.kt +++ /dev/null @@ -1,399 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db - -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import de.gematik.ti.erp.app.db.converter.CertificateConverter -import de.gematik.ti.erp.app.db.converter.DateConverter -import de.gematik.ti.erp.app.db.converter.ProfileColorsConverter -import de.gematik.ti.erp.app.db.converter.TruststoreConverter -import de.gematik.ti.erp.app.db.daos.ActiveProfileDao -import de.gematik.ti.erp.app.db.daos.AttestationDao -import de.gematik.ti.erp.app.db.daos.CommunicationDao -import de.gematik.ti.erp.app.db.daos.IdpAuthenticationDataDao -import de.gematik.ti.erp.app.db.daos.IdpConfigurationDao -import de.gematik.ti.erp.app.db.daos.ProfileDao -import de.gematik.ti.erp.app.db.daos.SettingsDao -import de.gematik.ti.erp.app.db.daos.TaskDao -import de.gematik.ti.erp.app.db.daos.TruststoreDao -import de.gematik.ti.erp.app.db.entities.ActiveProfile -import de.gematik.ti.erp.app.db.entities.AuditEventSimple -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.IdpAuthenticationDataEntity -import de.gematik.ti.erp.app.db.entities.IdpConfiguration -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.db.entities.ProfileEntity -import de.gematik.ti.erp.app.db.entities.SafetynetAttestationEntity -import de.gematik.ti.erp.app.db.entities.Settings -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.db.entities.TaskStatus -import de.gematik.ti.erp.app.db.entities.TruststoreEntity -import de.gematik.ti.erp.app.settings.usecase.DEFAULT_PROFILE_NAME -import javax.inject.Singleton - -const val DB_VERSION = 26 - -@Singleton -@Database( - entities = [ - Task::class, - AuditEventSimple::class, - IdpConfiguration::class, - IdpAuthenticationDataEntity::class, - ProfileEntity::class, - Settings::class, - TruststoreEntity::class, - Communication::class, - LowDetailEventSimple::class, - MedicationDispenseSimple::class, - SafetynetAttestationEntity::class, - ActiveProfile::class - ], - version = DB_VERSION, - exportSchema = true -) -@TypeConverters( - DateConverter::class, - TruststoreConverter::class, - CertificateConverter::class, - ProfileColorsConverter::class -) -abstract class AppDatabase : RoomDatabase() { - abstract fun taskDao(): TaskDao - abstract fun idpInfoDao(): IdpConfigurationDao - abstract fun idpAuthDataDao(): IdpAuthenticationDataDao - abstract fun settingsDao(): SettingsDao - abstract fun profileDao(): ProfileDao - abstract fun truststoreDao(): TruststoreDao - abstract fun communicationsDao(): CommunicationDao - abstract fun attestationDao(): AttestationDao - abstract fun activeProfileDao(): ActiveProfileDao -} - -val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE tasks ADD COLUMN status TEXT") - database.execSQL("CREATE TABLE IF NOT EXISTS `medicationDispense` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, PRIMARY KEY(`taskId`))") - } -} - -val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE IF EXISTS idpConfiguration") - database.execSQL("CREATE TABLE IF NOT EXISTS `idpConfiguration` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))") - database.execSQL("CREATE TABLE IF NOT EXISTS `idpAuthenticationDataEntity` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))") - } -} - -val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE medicationDispense ADD COLUMN text TEXT") - database.execSQL("ALTER TABLE medicationDispense ADD COLUMN type INT") - } -} - -val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "CREATE TABLE `medicationDispense_new` (`taskId` TEXT NOT NULL, `patientIdentifier` TEXT NOT NULL, `uniqueIdentifier` TEXT NOT NULL, `wasSubstituted` INTEGER NOT NULL, `dosageInstruction` TEXT NOT NULL, `performer` TEXT NOT NULL, `whenHandedOver` TEXT NOT NULL, `text` TEXT, `type` TEXT, PRIMARY KEY(`taskId`))" - ) - database.execSQL( - "INSERT INTO `medicationDispense_new` (`taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text`) SELECT `taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text` FROM `medicationDispense`" - ) - database.execSQL("DROP TABLE `medicationDispense`") - database.execSQL("ALTER TABLE `medicationDispense_new` RENAME TO `medicationDispense`") - } -} - -val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE settings ADD COLUMN password_salt BLOB") - database.execSQL("ALTER TABLE settings ADD COLUMN password_hash BLOB") - } -} - -val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE settings ADD COLUMN zoomEnabled INTEGER NOT NULL DEFAULT 0") - } -} - -val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE settings ADD COLUMN authenticationFails INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE settings ADD COLUMN userHasAcceptedInsecureDevice INTEGER NOT NULL DEFAULT 0") - } -} - -val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_name` TEXT NOT NULL DEFAULT ''") - database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_locationEnabled` INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_filterReady` INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_filterDeliveryService` INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_filterOnlineService` INTEGER NOT NULL DEFAULT 0") - database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_filterOpenNow` INTEGER NOT NULL DEFAULT 0") - } -} - -val MIGRATION_9_10 = object : Migration(9, 10) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("CREATE TABLE IF NOT EXISTS `safetynetattestations` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, ourNonce BLOB NOT NULL, PRIMARY KEY(`id`))") - } -} - -val MIGRATION_10_11 = object : Migration(10, 11) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE `healthCardUsers`") - database.execSQL("CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT)") - database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `profiles` (`name`)") - - database.execSQL("CREATE TABLE IF NOT EXISTS `activeProfile` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))") - - // we could insert a dummy user here - since we might need one to not violate foreign key constraint - and then overwrite it's name and things except id later - database.execSQL( - "INSERT INTO `profiles` (`id`, `name`, `insuranceNumber`) VALUES(0, '', NULL)" - ) - database.execSQL("INSERT INTO `activeProfile` (`id`, `profileName`) VALUES (0, '$DEFAULT_PROFILE_NAME')") - database.execSQL( - "CREATE TABLE IF NOT EXISTS `tasks_new` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL DEFAULT '', `accessCode` TEXT NOT NULL, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE)", - ) - database.execSQL("CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `tasks_new` (`profileName`)") - database.execSQL( - "INSERT INTO `tasks_new` (`taskId`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionName`, `redeemedOn`, `rawKBVBundle`) select `taskId`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionName`, `redeemedOn`, `rawKBVBundle` FROM `tasks`" - ) - database.execSQL( - "DROP table tasks" - ) - database.execSQL( - "ALTER TABLE tasks_new RENAME TO tasks" - ) - - // migration of communications - database.execSQL( - "CREATE TABLE IF NOT EXISTS `communications_new` (`communicationId` TEXT NOT NULL, `profile` TEXT NOT NULL, `profileName` TEXT NOT NULL DEFAULT '', `time` TEXT NOT NULL, `taskId` TEXT NOT NULL, `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`taskId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )" - ) - database.execSQL( - "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `communications_new` (`profileName`)" - ) - database.execSQL( - "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `communications_new` (`taskId`)" - ) - - database.execSQL( - """ - INSERT INTO `communications_new` ( - `communicationId`, - `profile`, - `time`, - `taskId`, - `telematicsId`, - `kbvUserId`, - `payload`, - `consumed` - ) SELECT - `communicationId`, - `profile`, - `time`, - `taskId`, - `telematicsId`, - `kbvUserId`, - `payload`, - `consumed` - FROM communications WHERE taskId IN (SELECT taskId FROM tasks); - """.trimIndent() - ) - database.execSQL( - "DROP TABLE communications" - ) - database.execSQL( - "ALTER TABLE communications_new RENAME TO communications" - ) - - // migration of idpAuthenticationDataEntity (adds foreign key profileName, adds CAN - database.execSQL( - "CREATE TABLE IF NOT EXISTS `idpAuthenticationDataEntity_new` (`profileName` TEXT NOT NULL DEFAULT '', `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE)" - ) - database.execSQL( - "CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `idpAuthenticationDataEntity_new` (`profileName`)" - ) - database.execSQL( - "INSERT INTO `idpAuthenticationDataEntity_new` (`id`,`singleSignOnToken`, `singleSignOnTokenScope`, `healthCardCertificate`, `aliasOfSecureElementEntry`) select `id`,`singleSignOnToken`, `singleSignOnTokenScope`, `healthCardCertificate`, `aliasOfSecureElementEntry` from `idpAuthenticationDataEntity`" - ) - database.execSQL( - "DROP TABLE idpAuthenticationDataEntity" - ) - database.execSQL( - "ALTER TABLE idpAuthenticationDataEntity_new RENAME TO idpAuthenticationDataEntity" - ) - } -} - -val MIGRATION_11_12 = object : Migration(11, 12) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE profiles ADD COLUMN `color` TEXT NOT NULL DEFAULT 'SPRING_GRAY'") - } -} - -val MIGRATION_12_13 = object : Migration(12, 13) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE IF EXISTS idpConfiguration") - database.execSQL("CREATE TABLE IF NOT EXISTS `idpConfiguration` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `issueTimestamp` INTEGER NOT NULL, `externalAuthorizationIDsEndpoint` TEXT ,`thirdPartyAuthorizationEndpoint` TEXT ,`id` INTEGER NOT NULL, PRIMARY KEY(`id`))") - } -} - -val MIGRATION_13_14 = object : Migration(13, 14) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE profiles ADD COLUMN `lastAuthenticated` INTEGER") - database.execSQL("ALTER TABLE idpAuthenticationDataEntity ADD COLUMN `singleSignOnTokenValidOn` INTEGER") - database.execSQL("ALTER TABLE idpAuthenticationDataEntity ADD COLUMN `singleSignOnTokenExpiresOn` INTEGER") - } -} - -val MIGRATION_14_15 = object : Migration(14, 15) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE IF EXISTS auditEvents") - database.execSQL("CREATE TABLE IF NOT EXISTS `auditEvents` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`))") - } -} - -val MIGRATION_15_16 = object : Migration(15, 16) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("UPDATE tasks SET status='${TaskStatus.Other}' WHERE status IS NOT NULL") - } -} - -val MIGRATION_16_17 = object : Migration(16, 17) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE IF EXISTS auditEvents") - database.execSQL("CREATE TABLE IF NOT EXISTS `auditEvents` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )") - database.execSQL("CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `auditEvents` (`profileName`)") - - database.execSQL("ALTER TABLE profiles ADD COLUMN `lastAuditEventSynced` Text") - } -} - -val MIGRATION_17_18 = object : Migration(17, 18) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE IF EXISTS auditEvents") - database.execSQL("CREATE TABLE IF NOT EXISTS `auditEvents` (`id` TEXT NOT NULL, `locale` TEXT NOT NULL, `profileName` TEXT NOT NULL, `text` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `taskId` TEXT NOT NULL, PRIMARY KEY(`id`, `locale`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE )") - database.execSQL("CREATE INDEX IF NOT EXISTS `index_auditEvents_profileName` ON `auditEvents` (`profileName`)") - } -} - -val MIGRATION_18_19 = object : Migration(18, 19) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE profiles ADD COLUMN `lastTaskSynced` INTEGER") - } -} - -val MIGRATION_19_20 = object : Migration(19, 20) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "DROP TABLE idpAuthenticationDataEntity" - ) - - database.execSQL( - "CREATE TABLE IF NOT EXISTS `idpAuthenticationDataEntity` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `singleSignOnTokenValidOn` INTEGER, `singleSignOnTokenExpiresOn` INTEGER, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE)" - ) - } -} - -val MIGRATION_20_21 = object : Migration(20, 21) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "CREATE TABLE IF NOT EXISTS `tasks_new` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `organization` TEXT, `medicationText` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `scanSessionName` TEXT, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE)", - ) - database.execSQL( - "INSERT INTO `tasks_new` (`taskId`, `profileName`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionName`, `redeemedOn`, `rawKBVBundle`) select `taskId`, `profileName`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionName`, `redeemedOn`, `rawKBVBundle` FROM `tasks`" - ) - database.execSQL( - "DROP table tasks" - ) - database.execSQL( - "ALTER TABLE tasks_new RENAME TO tasks" - ) - - database.execSQL("CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `tasks` (`profileName`)") - } -} - -val MIGRATION_21_22 = object : Migration(21, 22) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "DROP TABLE idpAuthenticationDataEntity" - ) - database.execSQL( - "CREATE TABLE IF NOT EXISTS `idpAuthenticationDataEntity` (`profileName` TEXT NOT NULL, `singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `cardAccessNumber` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `singleSignOnTokenValidOn` TEXT, `singleSignOnTokenExpiresOn` TEXT, PRIMARY KEY(`profileName`), FOREIGN KEY(`profileName`) REFERENCES `profiles`(`name`) ON UPDATE CASCADE ON DELETE CASCADE)" - ) - database.execSQL( - "DROP TABLE profiles" - ) - database.execSQL( - "CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT, `color` TEXT NOT NULL DEFAULT '${ProfileColorNames.SPRING_GRAY}', `lastAuthenticated` TEXT DEFAULT NULL, `lastAuditEventSynced` TEXT DEFAULT NULL, `lastTaskSynced` TEXT DEFAULT NULL)" - ) - database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `profiles` (`name`)") - database.execSQL( - "INSERT INTO `profiles` (`id`, `name`, `insuranceNumber`) VALUES(0, '', NULL)" - ) - } -} - -val MIGRATION_22_23 = object : Migration(22, 23) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE profiles ADD COLUMN `insurantName` TEXT") - database.execSQL("ALTER TABLE profiles ADD COLUMN `insuranceName` TEXT") - database.execSQL("ALTER TABLE profiles RENAME COLUMN insuranceNumber TO insuranceIdentifier") - } -} - -val MIGRATION_23_24 = object : Migration(23, 24) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("DROP TABLE IF EXISTS idpConfiguration") - database.execSQL("CREATE TABLE IF NOT EXISTS `idpConfiguration` (`authorizationEndpoint` TEXT NOT NULL, `ssoEndpoint` TEXT NOT NULL, `tokenEndpoint` TEXT NOT NULL, `pairingEndpoint` TEXT NOT NULL, `authenticationEndpoint` TEXT NOT NULL, `pukIdpEncEndpoint` TEXT NOT NULL, `pukIdpSigEndpoint` TEXT NOT NULL, `certificate` BLOB NOT NULL, `expirationTimestamp` TEXT NOT NULL, `issueTimestamp` TEXT NOT NULL, `externalAuthorizationIDsEndpoint` TEXT ,`thirdPartyAuthorizationEndpoint` TEXT ,`id` INTEGER NOT NULL, PRIMARY KEY(`id`))") - } -} - -val MIGRATION_24_25 = object : Migration(24, 25) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE settings ADD COLUMN `dataProtectionVersionAccepted` TEXT NOT NULL DEFAULT '2021-10-15'") - } -} - -val MIGRATION_25_26 = object : Migration(25, 26) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL( - "DROP TABLE profiles" - ) - database.execSQL( - "CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insurantName` TEXT, `insuranceName` TEXT, `insuranceIdentifier` TEXT, `color` TEXT NOT NULL DEFAULT '${ProfileColorNames.SPRING_GRAY}', `lastAuthenticated` TEXT DEFAULT NULL, `lastAuditEventSynced` TEXT DEFAULT NULL, `lastTaskSynced` TEXT DEFAULT NULL)" - ) - database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `profiles` (`name`)") - database.execSQL( - "INSERT INTO `profiles` (`id`, `name`, `insuranceIdentifier`) VALUES(0, '$DEFAULT_PROFILE_NAME', NULL)" - ) - database.execSQL("INSERT OR REPLACE INTO `activeProfile` (`id`, `profileName`) VALUES (0, '$DEFAULT_PROFILE_NAME')") - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/converter/DateConverter.kt b/android/src/main/java/de/gematik/ti/erp/app/db/converter/DateConverter.kt deleted file mode 100644 index 89d8ca85..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/converter/DateConverter.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.converter - -import androidx.room.TypeConverter -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter - -class DateConverter { - private val offsetDateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME - - @TypeConverter - fun toInstant(value: String?): Instant? { - return value?.let { - Instant.parse(it) - } - } - - @TypeConverter - fun fromInstant(timestamp: Instant?): String? { - return timestamp?.toString() - } - - @TypeConverter - fun toOffsetDateTime(value: String?): OffsetDateTime? { - return value?.let { - OffsetDateTime.parse(it) - } - } - - @TypeConverter - fun fromOffsetDateTime(date: OffsetDateTime?): String? { - return date?.format(offsetDateTimeFormatter) - } - - @TypeConverter - fun toLocalDate(value: String?): LocalDate? { - return value?.let { - LocalDate.parse(it) - } - } - - @TypeConverter - fun fromLocalDate(date: LocalDate?): String? { - return date?.format(DateTimeFormatter.ISO_DATE) - } - - @TypeConverter - fun toLocalDateTime(value: String?): LocalDateTime? { - return value?.let { - LocalDateTime.parse(it) - } - } - - @TypeConverter - fun fromLocalDateTime(date: LocalDateTime?): String? { - return date?.format(DateTimeFormatter.ISO_DATE_TIME) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/converter/TruststoreConverter.kt b/android/src/main/java/de/gematik/ti/erp/app/db/converter/TruststoreConverter.kt deleted file mode 100644 index 12dca8a6..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/converter/TruststoreConverter.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.converter - -import androidx.room.ProvidedTypeConverter -import androidx.room.TypeConverter -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList -import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList - -@ProvidedTypeConverter -class TruststoreConverter(moshi: Moshi) { - private val adapterCerts = moshi.adapter(UntrustedCertList::class.java) - private val adapterOCSP = moshi.adapter(UntrustedOCSPList::class.java) - - @TypeConverter - fun fromUntrustedCertList(certList: UntrustedCertList?): String? { - return adapterCerts.toJson(certList) - } - - @TypeConverter - fun toUntrustedCertList(certList: String?): UntrustedCertList? { - return certList?.let { adapterCerts.fromJson(it) } - } - - @TypeConverter - fun fromUntrustedOCSPList(certList: UntrustedOCSPList?): String? { - return adapterOCSP.toJson(certList) - } - - @TypeConverter - fun toUntrustedOCSPList(ocspList: String?): UntrustedOCSPList? { - return ocspList?.let { adapterOCSP.fromJson(it) } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/ActiveSessionDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/ActiveSessionDao.kt deleted file mode 100644 index 22585e28..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/ActiveSessionDao.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.ActiveProfile -import kotlinx.coroutines.flow.Flow - -@Dao -interface ActiveProfileDao { - @Query("SELECT * FROM activeProfile") - fun activeProfileFlow(): Flow - - @Query("SELECT * FROM activeProfile") - suspend fun activeProfile(): ActiveProfile? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertActiveProfile(activeProfile: ActiveProfile) - - @Query("UPDATE activeProfile SET profileName = :profileName WHERE id = 0") - suspend fun updateActiveProfile(profileName: String) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/CommunicationDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/CommunicationDao.kt deleted file mode 100644 index e4343289..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/CommunicationDao.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import kotlinx.coroutines.flow.Flow - -@Dao -interface CommunicationDao { - - @Query("SELECT * FROM communications WHERE profile = :profile AND profileName = :userProfile") - fun getAllCommunications( - profile: CommunicationProfile, - userProfile: String - ): Flow> - - @Query("SELECT * FROM communications WHERE profile = :profile AND consumed = :consumed AND profileName = :userProfile") - fun getAllUnreadCommunications( - profile: CommunicationProfile, - consumed: Boolean = false, - userProfile: String - ): Flow> - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertMultipleCommunications(vararg communication: Communication) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertCommunication(communication: Communication) - - @Query("UPDATE communications SET consumed = :consumed WHERE communicationId = :communicationId") - suspend fun updateCommunication(communicationId: String, consumed: Boolean) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/IdpDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/IdpDao.kt deleted file mode 100644 index 87465066..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/IdpDao.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.IdpAuthenticationDataEntity -import de.gematik.ti.erp.app.db.entities.IdpConfiguration -import kotlinx.coroutines.flow.Flow -import java.time.Instant - -@Dao -interface IdpConfigurationDao { - - @Query("SELECT * FROM idpConfiguration") - suspend fun getIdpConfiguration(): IdpConfiguration? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertIdpConfiguration(idpConfiguration: IdpConfiguration) - - @Query("DELETE FROM idpConfiguration") - suspend fun clearIdpConfigurationTable() -} - -@Dao -interface IdpAuthenticationDataDao { - - @Query("SELECT * FROM idpAuthenticationDataEntity WHERE profileName = :activeProfileName") - fun getIdpAuthenticationEntity(activeProfileName: String): Flow - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(data: IdpAuthenticationDataEntity) - - @Query("UPDATE idpAuthenticationDataEntity SET singleSignOnToken = null, singleSignOnTokenScope = null, singleSignOnTokenValidOn = null, singleSignOnTokenExpiresON = null, cardAccessNumber = null, healthCardCertificate = null, aliasOfSecureElementEntry = null WHERE profileName = :profileName") - suspend fun clear(profileName: String) - - @Query("UPDATE idpAuthenticationDataEntity SET singleSignOnToken = :token, singleSignOnTokenScope = :scope, singleSignOnTokenValidOn = :validOn, singleSignOnTokenExpiresON = :expiresOn WHERE profileName = :profileName") - suspend fun updateToken(profileName: String, token: String?, scope: IdpAuthenticationDataEntity.SingleSignOnTokenScope?, validOn: Instant?, expiresOn: Instant?) - - @Query("UPDATE idpAuthenticationDataEntity SET singleSignOnToken = :token, singleSignOnTokenValidOn = :validOn, singleSignOnTokenExpiresON = :expiresOn WHERE profileName = :profileName") - suspend fun updateTokenWithoutScope(profileName: String, token: String?, validOn: Instant?, expiresOn: Instant?) - - @Query("UPDATE idpAuthenticationDataEntity SET cardAccessNumber = :can WHERE profileName = :profileName") - suspend fun updateCardAccessNumber(profileName: String, can: String?) - - @Query("SELECT cardAccessNumber FROM idpAuthenticationDataEntity WHERE profileName = :profileName") - fun cardAccessNumber(profileName: String): Flow - - @Query("UPDATE idpAuthenticationDataEntity SET healthCardCertificate = :cert WHERE profileName = :profileName") - suspend fun updateHealthCardCert(profileName: String, cert: ByteArray?) - - @Query("UPDATE idpAuthenticationDataEntity SET aliasOfSecureElementEntry = :alias WHERE profileName = :profileName") - suspend fun updateAliasOfSecureElement(profileName: String, alias: ByteArray?) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/ProfileDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/ProfileDao.kt deleted file mode 100644 index 80ae16d1..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/ProfileDao.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.ProfileEntity -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import kotlinx.coroutines.flow.Flow -import java.time.Instant -import java.time.OffsetDateTime - -@Dao -interface ProfileDao { - - @Query("SELECT * FROM profiles") - fun getAllProfilesFlow(): Flow> - - @Query("SELECT * FROM profiles") - suspend fun getAllProfiles(): List - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertProfile(profile: ProfileEntity) - - @Query("SELECT COUNT(*) FROM profiles WHERE name = :profileName") - suspend fun countProfilesWithName(profileName: String): Int - - @Query("DELETE FROM profiles WHERE name = :profileName") - suspend fun removeProfileByName(profileName: String) - - @Query("UPDATE profiles SET name = :profileName WHERE id = :profileId") - suspend fun updateProfileName(profileId: Int, profileName: String) - - @Query("UPDATE profiles SET name = :updatedName WHERE name = :currentName") - suspend fun updateProfileName(currentName: String, updatedName: String) - - @Query("SELECT * FROM profiles WHERE id = :profileId") - fun loadProfile(profileId: Int): Flow - - @Query("UPDATE profiles SET color = :color WHERE name = :profileName") - suspend fun updateProfileColor(profileName: String, color: ProfileColorNames) - - @Query("UPDATE profiles SET lastAuthenticated = :lastAuthenticated WHERE name = :profileName") - suspend fun updateLastAuthenticated(lastAuthenticated: Instant, profileName: String) - - @Query("SELECT lastAuthenticated FROM profiles WHERE id = :profileId") - fun getLastAuthenticated(profileId: Int): Flow - - @Query("UPDATE profiles SET lastAuditEventSynced = :lastAuditEventSynced WHERE name = :profileName") - suspend fun updateAuditEventSynced(lastAuditEventSynced: OffsetDateTime, profileName: String) - - @Query("SELECT lastAuditEventSynced FROM profiles WHERE name = :profileName") - suspend fun getLastAuditEventSynced(profileName: String): OffsetDateTime? - - @Query("UPDATE profiles SET lastTaskSynced = :lastSynced WHERE name = :profileName") - suspend fun updateLastTaskSynced(profileName: String, lastSynced: Instant?) - - @Query("SELECT lastTaskSynced FROM profiles WHERE name = :profileName") - suspend fun getLastTaskSynced(profileName: String): Instant? - - @Query("UPDATE profiles SET insurantName = :insurantName, insuranceIdentifier = :insuranceIdentifier, insuranceName = :insuranceName WHERE name = :profileName") - suspend fun setInsuranceInformation(profileName: String, insurantName: String, insuranceIdentifier: String, insuranceName: String) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/SettingsDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/SettingsDao.kt deleted file mode 100644 index 9d0fe04d..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/SettingsDao.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.Settings -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import kotlinx.coroutines.flow.Flow -import java.time.LocalDate - -@Dao -interface SettingsDao { - - @Query("SELECT * FROM settings LIMIT 1") - fun getSettings(): Flow - - @Query("SELECT COUNT(*) FROM settings LIMIT 1") - fun isNotEmpty(): Boolean - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertSettings(settings: Settings) - - @Query( - """UPDATE settings SET - pharmacySearch_name = :name, - pharmacySearch_locationEnabled = :locationEnabled, - pharmacySearch_filterReady = :filterReady, - pharmacySearch_filterDeliveryService = :filterDeliveryService, - pharmacySearch_filterOnlineService = :filterOnlineService, - pharmacySearch_filterOpenNow = :filterOpenNow - """ - ) - suspend fun updatePharmacySearch( - name: String, - locationEnabled: Boolean, - filterReady: Boolean, - filterDeliveryService: Boolean, - filterOnlineService: Boolean, - filterOpenNow: Boolean - ) - - @Query("UPDATE settings SET authenticationMethod = :authenticationMethod, password_salt = :salt, password_hash = :hash") - suspend fun updateAuthenticationMethod( - authenticationMethod: SettingsAuthenticationMethod, - salt: ByteArray? = null, - hash: ByteArray? = null - ) - - @Query("UPDATE settings SET zoomEnabled = :enabled") - suspend fun updateZoom(enabled: Boolean) - - @Query("UPDATE settings SET authenticationFails = authenticationFails + 1") - suspend fun incrementNumberOfAuthenticationFailures() - - @Query("UPDATE settings SET authenticationFails = 0") - suspend fun resetNumberOfAuthenticationFailures() - - @Query("UPDATE settings SET userHasAcceptedInsecureDevice = 1") - suspend fun acceptInsecureDevice() - - @Query("UPDATE settings SET dataProtectionVersionAccepted = :date") - suspend fun acceptDataProtectionVersion(date: LocalDate) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/TaskDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/TaskDao.kt deleted file mode 100644 index 5c04d056..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/TaskDao.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.daos - -import androidx.paging.DataSource -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.RoomWarnings -import androidx.room.Transaction -import androidx.room.Update -import de.gematik.ti.erp.app.db.entities.AuditEventSimple -import de.gematik.ti.erp.app.db.entities.AuditEventWithMedicationText -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.db.entities.TaskWithMedicationDispense -import kotlinx.coroutines.flow.Flow -import java.time.OffsetDateTime - -@Dao -interface TaskDao { - - @Query("SELECT * from tasks WHERE profileName = :profileName ORDER BY authoredOn DESC") - fun getAllTasks(profileName: String): Flow> - - @Query("SELECT taskId FROM tasks WHERE profileName = :profileName") - suspend fun getAllTasksWithTaskIdOnly(profileName: String): List - - @Query("SELECT taskId FROM tasks") - suspend fun getAllTasksWithTaskIdOnly(): List - - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query(value = "SELECT taskId, profileName, accessCode, lastModified, organization, medicationText, expiresOn, acceptUntil, authoredOn, scannedOn, scanSessionEnd, nrInScanSession, scanSessionName, redeemedOn, status FROM tasks WHERE profileName = :profileName AND scannedOn IS NULL") - fun getSyncedTasksWithoutBundle(profileName: String): Flow> - - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT taskId, profileName, accessCode, lastModified, organization, medicationText, expiresOn, acceptUntil, authoredOn, scannedOn, scanSessionEnd, nrInScanSession, scanSessionName, redeemedOn FROM tasks WHERE profileName = :profileName AND scannedOn IS NOT NULL") - fun getScannedTasksWithoutBundle(profileName: String): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertMultipleTasks(vararg task: Task) - - @Transaction - suspend fun insertTask(task: Task) { - if (insertTaskIgnore(task) == -1L) { - insertTaskUpdate(task) - } - } - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertTaskIgnore(task: Task): Long - - @Update - suspend fun insertTaskUpdate(task: Task) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertMedicationDispenses(medicationDispense: MedicationDispenseSimple) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertLowDetailEvent(vararg lowDetailEvent: LowDetailEventSimple) - - @Query("DELETE FROM tasks WHERE taskId IN (:taskId)") - suspend fun deleteMultipleTasksByTaskId(vararg taskId: String) - - @Query("DELETE FROM auditEvents WHERE taskId = :taskId") - suspend fun deleteAuditEvents(taskId: String) - - @Transaction - @Query("SELECT * FROM tasks WHERE taskID = :taskId") - fun getTaskWithMedicationDispenseForTaskId(taskId: String): Flow - - @Query("SELECT * FROM tasks WHERE taskID IN (:taskIds)") - fun getTasksForTaskId(vararg taskIds: String): Flow> - - @Query("DELETE FROM tasks WHERE taskId = :taskId") - suspend fun deleteTaskByTaskId(taskId: String) - - @Query("UPDATE tasks SET redeemedOn = :redeemed WHERE scanSessionEnd IN (SELECT scanSessionEnd from tasks WHERE taskId IN (:taskIds) )") - suspend fun updateRedeemedOnForAllTasks(taskIds: List, redeemed: OffsetDateTime?) - - @Query("UPDATE tasks SET redeemedOn = :redeemed WHERE taskId = :taskId") - suspend fun updateRedeemedOnForSingleTask(taskId: String, redeemed: OffsetDateTime?) - - @Query("UPDATE tasks SET scanSessionName = :name WHERE scanSessionEnd = :scanSessionEnd") - fun updateScanSessionName(name: String?, scanSessionEnd: OffsetDateTime) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAuditEvents(vararg auditEvent: AuditEventSimple) - - @Query("SELECT * FROM auditEvents WHERE taskId = :taskId AND locale = :locale ORDER BY timestamp DESC LIMIT 50") - fun getAuditEventsInGivenLanguage(taskId: String, locale: String): Flow> - - @Query( - "SELECT t.medicationText, ae.timestamp, ae.text " + - "FROM auditEvents as ae LEFT JOIN tasks as t ON t.taskId = ae.taskId " + - "WHERE ae.profileName = :profileName ORDER BY timestamp DESC" - ) - fun getAuditEventsForProfileName(profileName: String): DataSource.Factory - - @Query("SELECT timestamp FROM auditEvents ORDER BY timestamp DESC LIMIT 1") - suspend fun getLatestAuditEventTimeStamp(): OffsetDateTime - - @Query("SELECT timestamp FROM auditEvents WHERE profileName = :profileName ORDER BY timestamp DESC LIMIT 1") - suspend fun getLatestAuditEventTimeStamp(profileName: String): OffsetDateTime? - - @Query("SELECT * FROM lowDetailEvents WHERE taskId = :taskId") - fun getLowDetailEvents(taskId: String): Flow> - - @Query("DELETE FROM lowDetailEvents WHERE taskId = :taskId") - fun deleteLowDetailEvents(taskId: String) - - @Query("SELECT * FROM tasks WHERE profileName = :profileName AND redeemedOn = :redeemedOn") - fun loadTasksForRedeemedOn(redeemedOn: OffsetDateTime, profileName: String): Flow> -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/AuditEventSimple.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/AuditEventSimple.kt deleted file mode 100644 index da9eaedb..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/AuditEventSimple.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import java.time.OffsetDateTime - -@Entity( - tableName = "auditEvents", primaryKeys = ["id", "locale"], - foreignKeys = [ - ForeignKey( - entity = ProfileEntity::class, - parentColumns = arrayOf("name"), - childColumns = arrayOf("profileName"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE - ) - ] -) -data class AuditEventSimple( - @ColumnInfo(name = "id") - val id: String, - @ColumnInfo(name = "locale") - val locale: String, - @ColumnInfo(index = true) - val profileName: String, - val text: String, - val timestamp: OffsetDateTime, - val taskId: String -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/Communication.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/Communication.kt deleted file mode 100644 index 3d00815c..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/Communication.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey - -const val COMMUNICATION_TYPE_DISP_REQ = "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" -const val COMMUNICATION_TYPE_REPLY = "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply" - -@Entity( - foreignKeys = [ - ForeignKey( - entity = Task::class, - parentColumns = arrayOf("taskId"), - childColumns = arrayOf("taskId"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.NO_ACTION - ), - ForeignKey( - entity = ProfileEntity::class, - parentColumns = arrayOf("name"), - childColumns = arrayOf("profileName"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE - ) - ], - tableName = "communications" -) -data class Communication( - @PrimaryKey - val communicationId: String, - val profile: CommunicationProfile, - @ColumnInfo(index = true) - val profileName: String, - val time: String, - @ColumnInfo(index = true) - val taskId: String, - val telematicsId: String, - val kbvUserId: String, - val payload: String?, - val consumed: Boolean = false -) - -enum class CommunicationProfile { - ErxCommunicationDispReq, ErxCommunicationReply -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/IdpEntities.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/IdpEntities.kt deleted file mode 100644 index 02f4be81..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/IdpEntities.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import org.bouncycastle.cert.X509CertificateHolder -import java.time.Instant - -@Entity(tableName = "idpConfiguration") -data class IdpConfiguration( - val authorizationEndpoint: String, - val ssoEndpoint: String, - val tokenEndpoint: String, - val pairingEndpoint: String, - val authenticationEndpoint: String, - val pukIdpEncEndpoint: String, - val pukIdpSigEndpoint: String, - val certificate: X509CertificateHolder, - val expirationTimestamp: Instant, - val issueTimestamp: Instant, - val externalAuthorizationIDsEndpoint: String?, - val thirdPartyAuthorizationEndpoint: String? -) { - @PrimaryKey - var id: Int = 0 -} - -@Entity( - foreignKeys = [ - ForeignKey( - entity = ProfileEntity::class, - parentColumns = arrayOf("name"), - childColumns = arrayOf("profileName"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE - ) - ], - tableName = "idpAuthenticationDataEntity" -) -data class IdpAuthenticationDataEntity( - @PrimaryKey - val profileName: String, - val singleSignOnToken: String? = null, - val singleSignOnTokenScope: SingleSignOnTokenScope? = null, - val singleSignOnTokenExpiresOn: Instant? = null, - val singleSignOnTokenValidOn: Instant? = null, - val cardAccessNumber: String? = null, - val healthCardCertificate: ByteArray? = null, - val aliasOfSecureElementEntry: ByteArray? = null -) { - enum class SingleSignOnTokenScope { - Default, - AlternateAuthentication - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as IdpAuthenticationDataEntity - - if (singleSignOnToken != other.singleSignOnToken) return false - if (singleSignOnTokenScope != other.singleSignOnTokenScope) return false - if (healthCardCertificate != null) { - if (other.healthCardCertificate == null) return false - if (!healthCardCertificate.contentEquals(other.healthCardCertificate)) return false - } else if (other.healthCardCertificate != null) return false - if (aliasOfSecureElementEntry != null) { - if (other.aliasOfSecureElementEntry == null) return false - if (!aliasOfSecureElementEntry.contentEquals(other.aliasOfSecureElementEntry)) return false - } else if (other.aliasOfSecureElementEntry != null) return false - if (profileName != other.profileName) return false - - return true - } - - override fun hashCode(): Int { - var result = singleSignOnToken?.hashCode() ?: 0 - result = 31 * result + (singleSignOnTokenScope?.hashCode() ?: 0) - result = 31 * result + (healthCardCertificate?.contentHashCode() ?: 0) - result = 31 * result + (aliasOfSecureElementEntry?.contentHashCode() ?: 0) - result = 31 * result + (profileName.hashCode()) - return result - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/MedicationDispenseSimple.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/MedicationDispenseSimple.kt deleted file mode 100644 index 6836fd2c..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/MedicationDispenseSimple.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import java.time.OffsetDateTime - -@Entity(tableName = "medicationDispense") -data class MedicationDispenseSimple( - - @ColumnInfo(name = "taskId") - @PrimaryKey - val taskId: String, - val patientIdentifier: String, // KVNR - val uniqueIdentifier: String, // PZN - val wasSubstituted: Boolean, - val text: String?, - val type: String?, - val dosageInstruction: String, - val performer: String, // Telematik-ID - val whenHandedOver: OffsetDateTime -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/ProfileEntity.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/ProfileEntity.kt deleted file mode 100644 index 3d746e0b..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/ProfileEntity.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey -import java.time.Instant -import java.time.OffsetDateTime - -@Entity(tableName = "profiles", indices = [Index(value = ["name"], unique = true)]) -data class ProfileEntity( - @PrimaryKey(autoGenerate = true) - val id: Int = 0, - val name: String, - val insurantName: String? = null, - val insuranceIdentifier: String? = null, - val insuranceName: String? = null, - val color: ProfileColorNames = ProfileColorNames.values().random(), - val lastAuthenticated: Instant? = null, - val lastAuditEventSynced: OffsetDateTime? = null, - val lastTaskSynced: Instant? = null -) - -enum class ProfileColorNames { - SPRING_GRAY, - SUN_DEW, - PINK, - TREE, - BLUE_MOON -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/Settings.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/Settings.kt deleted file mode 100644 index 3c8b38d6..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/Settings.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.PrimaryKey -import java.time.LocalDate - -enum class SettingsAuthenticationMethod { - HealthCard, - DeviceSecurity, - - @Deprecated("Keep for older app versions migrating to a newer one with mandatory app protection.") - Biometrics, - - @Deprecated("Keep for older app versions migrating to a newer one with mandatory app protection.") - DeviceCredentials, - Password, - - @Deprecated("Keep for older app versions migrating to a newer one with mandatory app protection.") - None, - Unspecified -} - -data class PasswordEntity( - val salt: ByteArray, - val hash: ByteArray -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as PasswordEntity - - if (!salt.contentEquals(other.salt)) return false - if (!hash.contentEquals(other.hash)) return false - - return true - } - - override fun hashCode(): Int { - var result = salt.contentHashCode() - result = 31 * result + hash.contentHashCode() - return result - } -} - -data class PharmacySearch( - val name: String, - val locationEnabled: Boolean, - val filterReady: Boolean, - val filterDeliveryService: Boolean, - val filterOnlineService: Boolean, - val filterOpenNow: Boolean -) - -@Entity(tableName = "settings") -data class Settings( - val authenticationMethod: SettingsAuthenticationMethod, - val authenticationFails: Int, - val zoomEnabled: Boolean, - @Embedded(prefix = "password_") - val password: PasswordEntity? = null, - @Embedded(prefix = "pharmacySearch_") - val pharmacySearch: PharmacySearch = PharmacySearch( - name = "", - locationEnabled = false, - filterReady = false, - filterDeliveryService = false, - filterOnlineService = false, - filterOpenNow = false - ), - val userHasAcceptedInsecureDevice: Boolean = false, - val dataProtectionVersionAccepted: LocalDate = LocalDate.of(2021, 10, 15) -) { - @PrimaryKey - var id: Long = 0 -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/Task.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/Task.kt deleted file mode 100644 index f9bf445f..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/Task.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.ColumnInfo -import androidx.room.ColumnInfo.BLOB -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import java.time.LocalDate -import java.time.OffsetDateTime - -/** - * @param authoredOn this is actually the authoredOn value of the medication request, cause this is what we want to display - */ -@Entity( - foreignKeys = [ - ForeignKey( - entity = ProfileEntity::class, - parentColumns = arrayOf("name"), - childColumns = arrayOf("profileName"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE - ) - ], - tableName = "tasks" -) -data class Task( - @ColumnInfo(name = "taskId") - @PrimaryKey - val taskId: String, - @ColumnInfo(index = true) - val profileName: String, - val accessCode: String? = null, - val lastModified: OffsetDateTime? = null, - - val organization: String? = null, // an organization can contain multiple authors - val medicationText: String? = null, - val expiresOn: LocalDate? = null, - val acceptUntil: LocalDate? = null, - val authoredOn: OffsetDateTime? = null, - - // synced only - val status: TaskStatus? = null, - - // scan only - val scannedOn: OffsetDateTime? = null, - val scanSessionEnd: OffsetDateTime? = null, - val nrInScanSession: Int? = null, // serial number of scanned tasks (e.g. 1, 2, ... 5) - val scanSessionName: String? = null, - val redeemedOn: OffsetDateTime? = null, - - @ColumnInfo(typeAffinity = BLOB) - val rawKBVBundle: ByteArray? = null, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Task - - if (taskId != other.taskId) return false - if (profileName != other.profileName) return false - if (accessCode != other.accessCode) return false - if (lastModified != other.lastModified) return false - if (organization != other.organization) return false - if (medicationText != other.medicationText) return false - if (expiresOn != other.expiresOn) return false - if (acceptUntil != other.acceptUntil) return false - if (authoredOn != other.authoredOn) return false - if (scannedOn != other.scannedOn) return false - if (scanSessionEnd != other.scanSessionEnd) return false - if (nrInScanSession != other.nrInScanSession) return false - if (scanSessionName != other.scanSessionName) return false - if (redeemedOn != other.redeemedOn) return false - if (rawKBVBundle != null) { - if (other.rawKBVBundle == null) return false - if (!rawKBVBundle.contentEquals(other.rawKBVBundle)) return false - } else if (other.rawKBVBundle != null) return false - - return true - } - - override fun hashCode(): Int { - var result = taskId.hashCode() - result = 31 * result + profileName.hashCode() - result = 31 * result + accessCode.hashCode() - result = 31 * result + (lastModified?.hashCode() ?: 0) - result = 31 * result + (organization?.hashCode() ?: 0) - result = 31 * result + (medicationText?.hashCode() ?: 0) - result = 31 * result + (expiresOn?.hashCode() ?: 0) - result = 31 * result + (acceptUntil?.hashCode() ?: 0) - result = 31 * result + (authoredOn?.hashCode() ?: 0) - result = 31 * result + (scannedOn?.hashCode() ?: 0) - result = 31 * result + (scanSessionEnd?.hashCode() ?: 0) - result = 31 * result + (nrInScanSession ?: 0) - result = 31 * result + (scanSessionName?.hashCode() ?: 0) - result = 31 * result + (redeemedOn?.hashCode() ?: 0) - result = 31 * result + (rawKBVBundle?.contentHashCode() ?: 0) - return result - } -} - -enum class TaskStatus { - Ready, InProgress, Completed, Other; - - companion object { - fun fromFhirTask(status: org.hl7.fhir.r4.model.Task.TaskStatus) = - when (status) { - org.hl7.fhir.r4.model.Task.TaskStatus.READY -> Ready - org.hl7.fhir.r4.model.Task.TaskStatus.INPROGRESS -> InProgress - org.hl7.fhir.r4.model.Task.TaskStatus.COMPLETED -> Completed - else -> Other - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/TaskWithMedicationDispense.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/TaskWithMedicationDispense.kt deleted file mode 100644 index b0d14cec..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/TaskWithMedicationDispense.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.db.entities - -import androidx.room.Embedded -import androidx.room.Relation - -data class TaskWithMedicationDispense( - @Embedded - val task: Task, - @Relation(parentColumn = "taskId", entityColumn = "taskId") - val medicationDispenseSimple: MedicationDispenseSimple? = null -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/demo/ui/DemoComponent.kt b/android/src/main/java/de/gematik/ti/erp/app/demo/ui/DemoComponent.kt deleted file mode 100644 index 7a96dfc6..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/demo/ui/DemoComponent.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.demo.ui - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ModelTraining -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.Spacer8 -import de.gematik.ti.erp.app.utils.compose.SpacerMaxWidth - -@Composable -fun DemoBanner(modifier: Modifier = Modifier, onClick: () -> Unit) { - TextButton( - onClick = onClick, - modifier = modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.yellow500, - contentColor = AppTheme.colors.yellow900 - ), - contentPadding = PaddingValues(8.dp), - shape = RectangleShape - ) { - Icon(Icons.Rounded.ModelTraining, null, modifier = Modifier.size(24.dp)) - Spacer8() - Text(stringResource(R.string.demo_mode_active)) - SpacerMaxWidth() - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/demo/usecase/DemoUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/demo/usecase/DemoUseCase.kt deleted file mode 100644 index d14f74fd..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/demo/usecase/DemoUseCase.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.demo.usecase - -import android.content.SharedPreferences -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import de.gematik.ti.erp.app.di.ApplicationDemoPreferences -import de.gematik.ti.erp.app.prescription.repository.PrescriptionDemoDataSource -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -private const val DEMOMODE_HAS_BEEN_SEEN = "DEMOMODE_HAS_BEEN_SEEN" - -@Singleton -class DemoUseCase @Inject constructor( - @ApplicationDemoPreferences - private val appDemoPrefs: SharedPreferences, - @Named("cardWallDemoSecurePrefs") - private val secDemoPrefs: SharedPreferences, - private val prescriptionDemoDataUseCase: PrescriptionDemoDataSource, -) : LifecycleObserver { - - val isDemoModeActive - get() = demoModeActive.value - - private val _demoModeActive = MutableStateFlow(false) - val demoModeActive: StateFlow - get() = _demoModeActive - - var authTokenReceived = MutableStateFlow(false) - - fun activateDemoMode() { - demoModeHasBeenSeen = true - _demoModeActive.value = true - } - - fun deactivateDemoMode() { - _demoModeActive.value = false - - authTokenReceived.value = false - - prescriptionDemoDataUseCase.reset() - clearPrefs() - } - - private var _demoModeHasBeenSeen: Boolean = - appDemoPrefs.getBoolean(DEMOMODE_HAS_BEEN_SEEN, false) - - var demoModeHasBeenSeen: Boolean - get() = _demoModeHasBeenSeen - set(value) { - if (value != _demoModeHasBeenSeen) { - appDemoPrefs.edit().putBoolean(DEMOMODE_HAS_BEEN_SEEN, value).apply() - _demoModeHasBeenSeen = value - } - } - - private fun clearPrefs() { - appDemoPrefs.edit().clear().apply() - secDemoPrefs.edit().clear().apply() - } - - @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) - fun onCreateApp() { - deactivateDemoMode() - } - - @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) - fun onDestroyApp() { - deactivateDemoMode() - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/AllModules.kt b/android/src/main/java/de/gematik/ti/erp/app/di/AllModules.kt new file mode 100644 index 00000000..2908aec8 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/di/AllModules.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.di + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.analytics.Analytics +import de.gematik.ti.erp.app.attestation.attestationModule +import de.gematik.ti.erp.app.cardunlock.cardUnlockModule +import de.gematik.ti.erp.app.cardwall.cardWallModule +import de.gematik.ti.erp.app.featuretoggle.FeatureToggleManager +import de.gematik.ti.erp.app.idp.idpModule +import de.gematik.ti.erp.app.orders.messagesModule +import de.gematik.ti.erp.app.orderhealthcard.orderHealthCardModule +import de.gematik.ti.erp.app.pharmacy.pharmacyModule +import de.gematik.ti.erp.app.prescription.prescriptionModule +import de.gematik.ti.erp.app.profiles.profilesModule +import de.gematik.ti.erp.app.protocol.protocolModule +import de.gematik.ti.erp.app.settings.settingsModule +import de.gematik.ti.erp.app.vau.vauModule +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +private const val PREFERENCES_FILE_NAME = "appPrefs" +private const val NETWORK_SECURE_PREFS_FILE_NAME = "networkingSecurePrefs" +private const val NETWORK_PREFS_FILE_NAME = "networkingPrefs" +private const val MASTER_KEY_ALIAS = "netWorkMasterKey" + +const val ApplicationPreferencesTag = "ApplicationPreferences" +const val NetworkPreferencesTag = "NetworkPreferences" +const val NetworkSecurePreferencesTag = "NetworkSecurePreferences" + +val allModules = DI.Module("allModules") { + bindSingleton { object : DispatchProvider {} } + + bindSingleton(ApplicationPreferencesTag) { + val context = instance() + context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + } + bindSingleton(NetworkPreferencesTag) { + val context = instance() + context.getSharedPreferences(NETWORK_PREFS_FILE_NAME, Context.MODE_PRIVATE) + } + bindSingleton(NetworkSecurePreferencesTag) { + val context = instance() + + EncryptedSharedPreferences.create( + context, + NETWORK_SECURE_PREFS_FILE_NAME, + MasterKey.Builder(context, MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + bindSingleton { EndpointHelper(networkPrefs = instance(NetworkPreferencesTag)) } + + bindSingleton { FeatureToggleManager(instance()) } + + bindSingleton { Analytics(instance()) } + + importAll( + attestationModule, + cardWallModule, + networkModule, + realmModule, + idpModule, + messagesModule, + orderHealthCardModule, + pharmacyModule, + prescriptionModule, + profilesModule, + protocolModule, + settingsModule, + vauModule, + cardUnlockModule + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/ApplicationModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/ApplicationModule.kt deleted file mode 100644 index 9faf42c8..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/ApplicationModule.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import android.content.Context -import android.content.SharedPreferences -import com.google.android.gms.safetynet.SafetyNet -import com.google.android.gms.safetynet.SafetyNetClient -import com.squareup.moshi.Moshi -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.attestation.Attestation -import de.gematik.ti.erp.app.attestation.AttestationReportGenerator -import de.gematik.ti.erp.app.attestation.SafetyNetAttestationReportGenerator -import de.gematik.ti.erp.app.attestation.SafetynetAttestation -import javax.inject.Qualifier - -const val PREFERENCES_FILE_NAME = "appPrefs" -const val DEMO_PREFERENCES_FILE_NAME = "appDemoPrefs" - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class TruststoreMoshi - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class ApplicationPreferences - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class ApplicationDemoPreferences - -@Module -@InstallIn(SingletonComponent::class) -object ApplicationModule { - - @Provides - @ApplicationPreferences - fun providesPrefs(@ApplicationContext context: Context): SharedPreferences { - return context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) - } - - @Provides - fun providesMoshi(): Moshi = Moshi.Builder().build() - - @ApplicationDemoPreferences - @Provides - fun providesDemoPrefs(@ApplicationContext context: Context): SharedPreferences { - return context.getSharedPreferences(DEMO_PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) - } - - @Provides - fun providesSafetyNetClient(@ApplicationContext context: Context): SafetyNetClient { - return SafetyNet.getClient(context) - } - - @Provides - fun providesAttestationValidator(): AttestationReportGenerator = SafetyNetAttestationReportGenerator() - - @Provides - fun providesAttestation( - @ApplicationContext context: Context, - client: SafetyNetClient, - ): Attestation = SafetynetAttestation(context, client) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/CardWallModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/CardWallModule.kt deleted file mode 100644 index 36d3b1f9..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/CardWallModule.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.scopes.ActivityScoped -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase -import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCaseDelegate -import de.gematik.ti.erp.app.cardwall.usecase.CardWallUseCase -import de.gematik.ti.erp.app.cardwall.usecase.CardWallUseCaseDelegate -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -private const val SECURE_PREFS_FILE_NAME = "CARD_WALL_PREFS" -private const val DEMO_SECURE_PREFS_FILE_NAME = "DEMO_CARD_WALL_PREFS" -private const val MASTER_KEY_ALIAS = "CARD_WALL_KEY_ALIAS" - -@ActivityScoped -class AppSharedPreferences @Inject constructor( - @ApplicationPreferences - private val appNormalPrefs: SharedPreferences, - @ApplicationDemoPreferences - private val appDemoPrefs: SharedPreferences, - private val demoUseCase: DemoUseCase -) { - operator fun invoke(): SharedPreferences = - if (demoUseCase.isDemoModeActive) { - appDemoPrefs - } else { - appNormalPrefs - } -} - -@ActivityScoped -class SecureCardWallSharedPreferences @Inject constructor( - @Named("cardWallSecurePrefs") - private val secNormalPrefs: SharedPreferences, - @Named("cardWallDemoSecurePrefs") - private val secDemoPrefs: SharedPreferences, - private val demoUseCase: DemoUseCase -) { - operator fun invoke(): SharedPreferences = - if (demoUseCase.isDemoModeActive) { - secDemoPrefs - } else { - secNormalPrefs - } -} - -@Module -@InstallIn(SingletonComponent::class) -object CardWallModule { - - @Singleton - @Provides - @Named("cardWallSecurePrefs") - fun providesSecPrefs(@ApplicationContext context: Context): SharedPreferences { - return EncryptedSharedPreferences.create( - context, - SECURE_PREFS_FILE_NAME, - MasterKey.Builder(context, MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } - - @Singleton - @Provides - @Named("cardWallDemoSecurePrefs") - fun providesSecDemoPrefs(@ApplicationContext context: Context): SharedPreferences { - return EncryptedSharedPreferences.create( - context, - DEMO_SECURE_PREFS_FILE_NAME, - MasterKey.Builder(context, MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } -} - -@Module -@InstallIn(SingletonComponent::class) -abstract class AbstractCardWallModule { - @Binds - abstract fun bindsCardWallUseCase(delegate: CardWallUseCaseDelegate): CardWallUseCase - - @Binds - abstract fun bindsAuthenticationUseCase(delegate: AuthenticationUseCaseDelegate): AuthenticationUseCase -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/LazyFhirParser.kt b/android/src/main/java/de/gematik/ti/erp/app/di/LazyFhirParser.kt deleted file mode 100644 index 394eea21..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/LazyFhirParser.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.parser.IParser -import ca.uhn.fhir.parser.IParserErrorHandler -import ca.uhn.fhir.rest.api.EncodingEnum -import org.hl7.fhir.instance.model.api.IBaseResource -import org.hl7.fhir.instance.model.api.IIdType -import java.io.InputStream -import java.io.Reader -import java.io.Writer - -class LazyFhirParser : IParser { - private val fhirParser: IParser by lazy { FhirContext.forR4().newJsonParser() } - - override fun encodeResourceToString(theResource: IBaseResource?): String { - return fhirParser.encodeResourceToString(theResource) - } - - override fun encodeResourceToWriter(theResource: IBaseResource?, theWriter: Writer?) { - return fhirParser.encodeResourceToWriter(theResource, theWriter) - } - - override fun getEncodeForceResourceId(): IIdType { - return fhirParser.getEncodeForceResourceId() - } - - override fun getEncoding(): EncodingEnum { - return fhirParser.getEncoding() - } - - override fun getPreferTypes(): MutableList> { - return fhirParser.getPreferTypes() - } - - override fun isOmitResourceId(): Boolean { - return fhirParser.isOmitResourceId() - } - - override fun getStripVersionsFromReferences(): Boolean { - return fhirParser.getStripVersionsFromReferences() - } - - override fun isSummaryMode(): Boolean { - return fhirParser.isSummaryMode() - } - - override fun parseResource(theResourceType: Class?, theReader: Reader?): T { - return fhirParser.parseResource(theResourceType, theReader) - } - - override fun parseResource(theResourceType: Class?, theInputStream: InputStream?): T { - return fhirParser.parseResource(theResourceType, theInputStream) - } - - override fun parseResource(theResourceType: Class?, theString: String?): T { - return fhirParser.parseResource(theResourceType, theString) - } - - override fun parseResource(theReader: Reader?): IBaseResource { - return fhirParser.parseResource(theReader) - } - - override fun parseResource(theInputStream: InputStream?): IBaseResource { - return fhirParser.parseResource(theInputStream) - } - - override fun parseResource(theMessageString: String?): IBaseResource { - return fhirParser.parseResource(theMessageString) - } - - override fun setDontEncodeElements(theDontEncodeElements: MutableCollection?): IParser { - return fhirParser.setDontEncodeElements(theDontEncodeElements) - } - - override fun setEncodeElements(theEncodeElements: MutableSet?): IParser { - return fhirParser.setEncodeElements(theEncodeElements) - } - - override fun setEncodeElementsAppliesToChildResourcesOnly(theEncodeElementsAppliesToChildResourcesOnly: Boolean) { - return fhirParser.setEncodeElementsAppliesToChildResourcesOnly(theEncodeElementsAppliesToChildResourcesOnly) - } - - override fun isEncodeElementsAppliesToChildResourcesOnly(): Boolean { - return fhirParser.isEncodeElementsAppliesToChildResourcesOnly() - } - - override fun setEncodeForceResourceId(theForceResourceId: IIdType?): IParser { - return fhirParser.setEncodeForceResourceId(theForceResourceId) - } - - override fun setOmitResourceId(theOmitResourceId: Boolean): IParser { - return fhirParser.setOmitResourceId(theOmitResourceId) - } - - override fun setParserErrorHandler(theErrorHandler: IParserErrorHandler?): IParser { - return fhirParser.setParserErrorHandler(theErrorHandler) - } - - override fun setPreferTypes(thePreferTypes: MutableList>?) { - return fhirParser.setPreferTypes(thePreferTypes) - } - - override fun setPrettyPrint(thePrettyPrint: Boolean): IParser { - return fhirParser.setPrettyPrint(thePrettyPrint) - } - - override fun setServerBaseUrl(theUrl: String?): IParser { - return fhirParser.setServerBaseUrl(theUrl) - } - - override fun setStripVersionsFromReferences(theStripVersionsFromReferences: Boolean?): IParser { - return fhirParser.setStripVersionsFromReferences(theStripVersionsFromReferences) - } - - override fun setOverrideResourceIdWithBundleEntryFullUrl(theOverrideResourceIdWithBundleEntryFullUrl: Boolean?): IParser { - return fhirParser.setOverrideResourceIdWithBundleEntryFullUrl(theOverrideResourceIdWithBundleEntryFullUrl) - } - - override fun setSummaryMode(theSummaryMode: Boolean): IParser { - return fhirParser.setSummaryMode(theSummaryMode) - } - - override fun setSuppressNarratives(theSuppressNarratives: Boolean): IParser { - return fhirParser.setSuppressNarratives(theSuppressNarratives) - } - - override fun setDontStripVersionsFromReferencesAtPaths(vararg thePaths: String?): IParser { - return fhirParser.setDontStripVersionsFromReferencesAtPaths(*thePaths) - } - - override fun setDontStripVersionsFromReferencesAtPaths(thePaths: MutableCollection?): IParser { - return fhirParser.setDontStripVersionsFromReferencesAtPaths(thePaths) - } - - override fun getDontStripVersionsFromReferencesAtPaths(): MutableSet { - return fhirParser.getDontStripVersionsFromReferencesAtPaths() - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/NetworkModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/NetworkModule.kt new file mode 100644 index 00000000..439f8b01 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/di/NetworkModule.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.di + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.api.ErpService +import de.gematik.ti.erp.app.api.PharmacyRedeemService +import de.gematik.ti.erp.app.api.PharmacySearchService +import de.gematik.ti.erp.app.idp.api.IdpService +import de.gematik.ti.erp.app.interceptor.ApiKeyHeaderInterceptor +import de.gematik.ti.erp.app.interceptor.BearerHeaderInterceptor +import de.gematik.ti.erp.app.interceptor.PharmacySearchInterceptor +import de.gematik.ti.erp.app.interceptor.UserAgentHeaderInterceptor +import de.gematik.ti.erp.app.vau.api.VauService +import de.gematik.ti.erp.app.vau.interceptor.VauChannelInterceptor +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import okhttp3.CipherSuite +import okhttp3.ConnectionSpec +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.TlsVersion +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import io.github.aakira.napier.Napier +import org.kodein.di.DI +import org.kodein.di.bindInstance +import org.kodein.di.bindMultiton +import org.kodein.di.bindProvider +import org.kodein.di.bindSingleton +import org.kodein.di.instance +import java.util.concurrent.TimeUnit + +private const val HTTP_CONNECTION_TIMEOUT = 10000L +private const val HTTP_READ_TIMEOUT = 10000L +private const val HTTP_WRITE_TIMEOUT = 10000L + +class NapierLogger(tagSuffix: String? = null) : HttpLoggingInterceptor.Logger { + private val tag = if (tagSuffix != null) { + "OkHttp $tagSuffix" + } else { + "OkHttp" + } + + override fun log(message: String) { + Napier.d(message, tag = tag) + } +} + +const val PrefixedLoggerTag = "PrefixedLogger" +const val JsonConverterFactoryTag = "JsonConverterFactory" +const val JsonFhirConverterFactoryTag = "JsonFhirConverterFactoryTag" + +@OptIn(ExperimentalSerializationApi::class) +val networkModule = DI.Module("Network Module") { + bindInstance { + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + bindSingleton(JsonConverterFactoryTag) { instance().asConverterFactory("application/json".toMediaType()) } + bindSingleton(JsonFhirConverterFactoryTag) { + instance().asConverterFactory("application/json+fhir".toMediaType()) + } + bindSingleton { + OkHttpClient.Builder() + .connectTimeout( + timeout = HTTP_CONNECTION_TIMEOUT, + unit = TimeUnit.MILLISECONDS + ) + .readTimeout( + timeout = HTTP_READ_TIMEOUT, + unit = TimeUnit.MILLISECONDS + ) + .writeTimeout( + timeout = HTTP_WRITE_TIMEOUT, + unit = TimeUnit.MILLISECONDS + ) + .connectionSpecs(getConnectionSpec()) + .build() + } + bindSingleton { UserAgentHeaderInterceptor() } + bindSingleton { ApiKeyHeaderInterceptor(instance()) } + bindSingleton { BearerHeaderInterceptor(instance()) } + + bindProvider { + HttpLoggingInterceptor(NapierLogger()).also { + it.setLevel(HttpLoggingInterceptor.Level.BODY) + } + } + + bindMultiton, HttpLoggingInterceptor>(PrefixedLoggerTag) { (tagSuffix, withBody) -> + HttpLoggingInterceptor(NapierLogger(tagSuffix)).also { + if (BuildKonfig.INTERNAL) { + if (withBody) { + it.setLevel(HttpLoggingInterceptor.Level.BODY) + } else { + it.setLevel(HttpLoggingInterceptor.Level.HEADERS) + } + } + } + } + + // IDP Service + bindSingleton { + val clientBuilder = instance().newBuilder() + val userAgentInterceptor = instance() + val endpointHelper = instance() + val apiKeyInterceptor = instance() + val loggingInterceptor = instance() + + val client = clientBuilder + .addInterceptor(userAgentInterceptor) + .addInterceptor(apiKeyInterceptor) + .addInterceptor(loggingInterceptor) + .followRedirects(false) + .build() + + Retrofit.Builder() + .client(client) + .baseUrl(endpointHelper.idpServiceUri) + .addConverterFactory(JWSConverterFactory()) + .addConverterFactory(instance(JsonConverterFactoryTag)) + .build() + .create(IdpService::class.java) + } + + // ERP Service + bindSingleton { + val clientBuilder = instance().newBuilder() + val vauChannelInterceptor = instance() + val userAgentInterceptor = instance() + val apiKeyInterceptor = instance() + val bearerInterceptor = instance() + val endpointHelper = instance() + val innerLoggingInterceptor = + instance, HttpLoggingInterceptor>(PrefixedLoggerTag, "[inner request]" to true) + val outerLoggingInterceptor = + instance, HttpLoggingInterceptor>(PrefixedLoggerTag, "[outer request]" to false) + + clientBuilder.cache(null) + + clientBuilder.addInterceptor(bearerInterceptor) + + clientBuilder.addInterceptor(innerLoggingInterceptor) + + clientBuilder.addInterceptor(vauChannelInterceptor) + + // user agent & dev headers at outer request + clientBuilder.addInterceptor(userAgentInterceptor) + clientBuilder.addInterceptor(apiKeyInterceptor) + + clientBuilder.addInterceptor(outerLoggingInterceptor) + + Retrofit.Builder() + .client(clientBuilder.build()) + .baseUrl(endpointHelper.eRezeptServiceUri) + .addConverterFactory(instance(JsonFhirConverterFactoryTag)) + .addConverterFactory(instance(JsonConverterFactoryTag)) + .build() + .create(ErpService::class.java) + } + + // The VAU service is only used to get CertList & OCSPList and NOT to post to the VAU endpoint + bindSingleton { + val clientBuilder = instance().newBuilder() + val userAgentInterceptor = instance() + val apiKeyInterceptor = instance() + val endpointHelper = instance() + val loggingInterceptor = instance() + + clientBuilder.addInterceptor(apiKeyInterceptor) + clientBuilder.addInterceptor(userAgentInterceptor) + clientBuilder.addInterceptor(loggingInterceptor) + + Retrofit.Builder() + .client(clientBuilder.build()) + .baseUrl(endpointHelper.eRezeptServiceUri) + .addConverterFactory(instance(JsonConverterFactoryTag)) + .build() + .create(VauService::class.java) + } + + // Pharmacy Redeem Service + bindSingleton { + val clientBuilder = instance().newBuilder() + val loggingInterceptor = instance() + + clientBuilder + .addInterceptor(loggingInterceptor) + + Retrofit.Builder() + .client(clientBuilder.build()) + .baseUrl("https://localhost") // unused but required + .build() + .create(PharmacyRedeemService::class.java) + } + + // Pharmacy Search Service + bindSingleton { + val clientBuilder = instance().newBuilder() + val endpointHelper = instance() + val loggingInterceptor = instance() + + clientBuilder + .addInterceptor(PharmacySearchInterceptor(instance())) + .addInterceptor(loggingInterceptor) + + Retrofit.Builder() + .client(clientBuilder.build()) + .baseUrl(endpointHelper.pharmacySearchBaseUri) + .addConverterFactory(instance(JsonConverterFactoryTag)) + .build() + .create(PharmacySearchService::class.java) + } +} + +private fun getConnectionSpec(): List = ConnectionSpec + .Builder(ConnectionSpec.RESTRICTED_TLS) + .tlsVersions( + TlsVersion.TLS_1_2, + TlsVersion.TLS_1_3 + ) + .cipherSuites( + // TLS 1.2 + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + // TLS 1.3 + CipherSuite.TLS_AES_128_GCM_SHA256, + CipherSuite.TLS_AES_256_GCM_SHA384, + CipherSuite.TLS_CHACHA20_POLY1305_SHA256 + ) + .build() + .let { + listOf(it) + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/NetworkingModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/NetworkingModule.kt deleted file mode 100644 index 9f002b56..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/NetworkingModule.kt +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import ca.uhn.fhir.parser.IParser -import com.squareup.moshi.Moshi -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.BuildKonfig -import de.gematik.ti.erp.app.api.ErpService -import de.gematik.ti.erp.app.api.FhirConverterFactory -import de.gematik.ti.erp.app.api.PharmacySearchService -import de.gematik.ti.erp.app.idp.api.IdpService -import de.gematik.ti.erp.app.idp.api.models.JWSAdapter -import de.gematik.ti.erp.app.idp.usecase.IdpUseCase -import de.gematik.ti.erp.app.interceptor.BearerHeadersInterceptor -import de.gematik.ti.erp.app.interceptor.PharmacySearchInterceptor -import de.gematik.ti.erp.app.interceptor.UserAgentHeaderInterceptor -import de.gematik.ti.erp.app.vau.api.VauService -import de.gematik.ti.erp.app.vau.api.model.OCSPAdapter -import de.gematik.ti.erp.app.vau.api.model.X509Adapter -import de.gematik.ti.erp.app.vau.api.model.X509ArrayAdapter -import de.gematik.ti.erp.app.vau.interceptor.VauChannelInterceptor -import okhttp3.CipherSuite -import okhttp3.ConnectionSpec -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.TlsVersion -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory -import java.util.concurrent.TimeUnit -import javax.inject.Named -import javax.inject.Qualifier -import javax.inject.Singleton - -private const val HTTP_CONNECTION_TIMEOUT = 10000L -private const val HTTP_READ_TIMEOUT = 10000L -private const val HTTP_WRITE_TIMEOUT = 10000L -private const val NETWORK_SECURE_PREFS_FILE_NAME = "networkingSecurePrefs" -private const val NETWORK_PREFS_FILE_NAME = "networkingPrefs" -private const val MASTER_KEY_ALIAS = "netWorkMasterKey" - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class DevelopReleaseHeaderInterceptor - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class UserAgentInterceptor - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class BearerInterceptor - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class NetworkSecureSharedPreferences - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class NetworkSharedPreferences - -@Module -@InstallIn(SingletonComponent::class) -class NetworkingModule { - - @Singleton - @Provides - fun idpService( - baseClient: OkHttpClient, - moshi: Moshi, - @UserAgentInterceptor userAgentInterceptor: Interceptor, - @DevelopReleaseHeaderInterceptor headersInterceptor: Interceptor, - endpointHelper: EndpointHelper - ): IdpService { - val client = baseClient.newBuilder() - .addInterceptor(headersInterceptor) - .addInterceptor(userAgentInterceptor) - .addInterceptor( - HttpLoggingInterceptor().also { - if (BuildKonfig.INTERNAL) it.setLevel(HttpLoggingInterceptor.Level.BODY) - } - ) - .followRedirects(false) - .build() - - return Retrofit.Builder() - .client(client) - .baseUrl(endpointHelper.idpServiceUri) - .addConverterFactory(JWSConverterFactory()) - .addConverterFactory( - MoshiConverterFactory.create( - moshi.newBuilder().add(JWSAdapter()).add(X509ArrayAdapter()).build() - ) - ) - .build() - .create(IdpService::class.java) - } - - @Named("userAgent") - @Provides - fun providesUserAgent(): String { - return BuildKonfig.USER_AGENT - } - - @UserAgentInterceptor - @Provides - fun providesUserAgentInterceptor(@Named("userAgent") userAgent: String): Interceptor = - UserAgentHeaderInterceptor(userAgent) - - @BearerInterceptor - @Provides - fun providesBearerInterceptor( - idpUseCase: IdpUseCase - ): Interceptor = - BearerHeadersInterceptor(idpUseCase) - - @Singleton - @Provides - fun eRpService( - baseClient: OkHttpClient, - fhirParser: IParser, - @BearerInterceptor bearerInterceptor: Interceptor, - @UserAgentInterceptor userAgentInterceptor: Interceptor, - @DevelopReleaseHeaderInterceptor devHeadersInterceptor: Interceptor, - endpointHelper: EndpointHelper, - vauChannelInterceptor: VauChannelInterceptor - ): ErpService { - val clientBuilder = baseClient.newBuilder() - clientBuilder.cache(null) - - clientBuilder.addInterceptor(bearerInterceptor) - - clientBuilder.addInterceptor( - HttpLoggingInterceptor(PrefixedLogger("inner request")).also { - if (BuildKonfig.INTERNAL) it.setLevel(HttpLoggingInterceptor.Level.BODY) - } - ) - - clientBuilder.addInterceptor(vauChannelInterceptor) - - // user agent & dev headers at outer request - clientBuilder.addInterceptor(userAgentInterceptor) - clientBuilder.addInterceptor(devHeadersInterceptor) - - clientBuilder.addInterceptor( - HttpLoggingInterceptor(PrefixedLogger("outer request")).also { - if (BuildKonfig.INTERNAL) it.setLevel(HttpLoggingInterceptor.Level.BODY) - } - ) - - return Retrofit.Builder() - .client(clientBuilder.build()) - .baseUrl(endpointHelper.eRezeptServiceUri) - .addConverterFactory(FhirConverterFactory.create(fhirParser)) - .addConverterFactory(MoshiConverterFactory.create()) - .build() - .create(ErpService::class.java) - } - - @Singleton - @Provides - fun pharmacyService( - baseClient: OkHttpClient, - fhirParser: IParser, - endpointHelper: EndpointHelper - ): PharmacySearchService { - val clientBuilder = baseClient.newBuilder().addInterceptor(PharmacySearchInterceptor()) - .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) - - return Retrofit.Builder() - .client(clientBuilder.build()) - .baseUrl(endpointHelper.pharmacySearchBaseUri) - .addConverterFactory(FhirConverterFactory.create(fhirParser)) - .addConverterFactory(MoshiConverterFactory.create()) - .build() - .create(PharmacySearchService::class.java) - } - - // The VAU service is only used to get CertList & OCSPList and NOT to post to the VAU endpoint - - @Singleton - @Provides - fun vauService( - baseClient: OkHttpClient, - moshi: Moshi, - endpointHelper: EndpointHelper, - @UserAgentInterceptor userAgentInterceptor: Interceptor, - @DevelopReleaseHeaderInterceptor devHeadersInterceptor: Interceptor - ): VauService { - val clientBuilder = baseClient.newBuilder() - - clientBuilder.addInterceptor(devHeadersInterceptor) - clientBuilder.addInterceptor(userAgentInterceptor) - clientBuilder.addInterceptor( - HttpLoggingInterceptor().also { - if (BuildKonfig.INTERNAL) it.setLevel(HttpLoggingInterceptor.Level.BODY) - } - ) - - return Retrofit.Builder() - .client(clientBuilder.build()) - .baseUrl(endpointHelper.eRezeptServiceUri) - .addConverterFactory( - MoshiConverterFactory.create( - moshi.newBuilder().add(OCSPAdapter()).add(X509Adapter()).build() - ) - ) - .build() - .create(VauService::class.java) - } - - @Singleton - @Provides - fun providesBaseOkHttpClient(): OkHttpClient { - return OkHttpClient.Builder() - .connectTimeout( - timeout = HTTP_CONNECTION_TIMEOUT, - unit = TimeUnit.MILLISECONDS - ) - .readTimeout( - timeout = HTTP_READ_TIMEOUT, - unit = TimeUnit.MILLISECONDS - ) - .writeTimeout( - timeout = HTTP_WRITE_TIMEOUT, - unit = TimeUnit.MILLISECONDS - ) - .connectionSpecs(getConnectionSpec()) - .build() - } - - @Singleton - @Provides - fun providesFhirParser(): IParser { - return LazyFhirParser() - } - - @Singleton - @Provides - @NetworkSecureSharedPreferences - fun providesSecPrefs(@ApplicationContext context: Context): SharedPreferences { - return EncryptedSharedPreferences.create( - context, - NETWORK_SECURE_PREFS_FILE_NAME, - MasterKey.Builder(context, MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } - - @NetworkSharedPreferences - @Singleton - @Provides - fun providesNetworkSharedPrefs(@ApplicationContext context: Context): SharedPreferences { - return context.getSharedPreferences(NETWORK_PREFS_FILE_NAME, Context.MODE_PRIVATE) - } - - private fun getConnectionSpec(): List = ConnectionSpec - .Builder(ConnectionSpec.RESTRICTED_TLS) - .tlsVersions( - TlsVersion.TLS_1_2, - TlsVersion.TLS_1_3 - ) - .cipherSuites( - // TLS 1.2 - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - // TLS 1.3 - CipherSuite.TLS_AES_128_GCM_SHA256, - CipherSuite.TLS_AES_256_GCM_SHA384, - CipherSuite.TLS_CHACHA20_POLY1305_SHA256 - ) - .build() - .let { - listOf(it) - } -} - -private class PrefixedLogger(val prefix: String) : HttpLoggingInterceptor.Logger { - override fun log(message: String) { - HttpLoggingInterceptor.Logger.DEFAULT.log("[$prefix] $message") - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/PrescriptionModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/PrescriptionModule.kt deleted file mode 100644 index baa09452..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/PrescriptionModule.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.DefaultDispatchProvider -import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCaseDelegate - -@Module -@InstallIn(SingletonComponent::class) -abstract class PrescriptionModule { - @Binds - abstract fun bindsDispatcher(default: DefaultDispatchProvider): DispatchProvider - - @Binds - abstract fun bindsPrescriptionUseCase(delegate: PrescriptionUseCaseDelegate): PrescriptionUseCase -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/RealmModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/RealmModule.kt new file mode 100644 index 00000000..8a2db1ee --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/di/RealmModule.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.di + +import android.content.Context +import android.content.SharedPreferences + +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +import de.gematik.ti.erp.app.BuildConfig +import de.gematik.ti.erp.app.MessageConversionException +import de.gematik.ti.erp.app.db.appSchemas +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.openRealmWith +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.secureRandomInstance +import org.jose4j.base64url.Base64 +import org.kodein.di.DI +import org.kodein.di.bindEagerSingleton +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +private const val ENCRYPTED_REALM_PREFS_FILE_NAME = "ENCRYPTED_REALM_PREFS_FILE_NAME" +private const val ENCRYPTED_REALM_PASSWORD_KEY = "ENCRYPTED_REALM_PASSWORD_KEY" +private const val REALM_MASTER_KEY_ALIAS = "REALM_DB_MASTER_KEY" + +private const val PassphraseSizeInBytes = 64 + +const val RealmDatabaseSecurePreferencesTag = "RealmDatabaseSecurePreferences" + +val realmModule = DI.Module("realmModule") { + bindSingleton(RealmDatabaseSecurePreferencesTag) { + val context = instance() + + EncryptedSharedPreferences.create( + context, + ENCRYPTED_REALM_PREFS_FILE_NAME, + MasterKey.Builder(context, REALM_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + bindEagerSingleton { + val securePrefs = instance(RealmDatabaseSecurePreferencesTag) + + try { + openRealmWith( + schemas = appSchemas, + configuration = { + it.encryptionKey(Base64.decode(getPassphrase(securePrefs))) + } + ).also { realm -> + realm.writeBlocking { + queryFirst()?.let { + it.latestAppVersionName = BuildConfig.VERSION_NAME + it.latestAppVersionCode = BuildConfig.VERSION_CODE + } + } + } + } catch (expected: Throwable) { + throw MessageConversionException(expected) + } + } +} + +private fun getPassphrase(securePrefs: SharedPreferences): String { + if (getPassword(securePrefs).isNullOrEmpty()) { + val passPhrase = generatePassPhrase() + storePassPhrase(securePrefs, passPhrase) + } + return getPassword(securePrefs) + ?: throw IllegalStateException("passphrase should not be empty") +} + +private fun generatePassPhrase(): String { + val passPhrase = ByteArray(PassphraseSizeInBytes).apply { + secureRandomInstance().nextBytes(this) + } + return Base64.encode(passPhrase) +} + +private fun storePassPhrase( + securePrefs: SharedPreferences, + passPhrase: String +) { + securePrefs.edit().putString( + ENCRYPTED_REALM_PASSWORD_KEY, + passPhrase + ) + .apply() +} + +private fun getPassword(securePrefs: SharedPreferences): String? { + return securePrefs.getString( + ENCRYPTED_REALM_PASSWORD_KEY, + null + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/RoomModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/RoomModule.kt deleted file mode 100644 index ccca8fd5..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/RoomModule.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import android.content.Context -import android.content.SharedPreferences -import androidx.room.Room -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.MIGRATION_10_11 -import de.gematik.ti.erp.app.db.MIGRATION_11_12 -import de.gematik.ti.erp.app.db.MIGRATION_12_13 -import de.gematik.ti.erp.app.db.MIGRATION_13_14 -import de.gematik.ti.erp.app.db.MIGRATION_14_15 -import de.gematik.ti.erp.app.db.MIGRATION_15_16 -import de.gematik.ti.erp.app.db.MIGRATION_16_17 -import de.gematik.ti.erp.app.db.MIGRATION_17_18 -import de.gematik.ti.erp.app.db.MIGRATION_18_19 -import de.gematik.ti.erp.app.db.MIGRATION_19_20 -import de.gematik.ti.erp.app.db.MIGRATION_1_2 -import de.gematik.ti.erp.app.db.MIGRATION_20_21 -import de.gematik.ti.erp.app.db.MIGRATION_21_22 -import de.gematik.ti.erp.app.db.MIGRATION_22_23 -import de.gematik.ti.erp.app.db.MIGRATION_23_24 -import de.gematik.ti.erp.app.db.MIGRATION_24_25 -import de.gematik.ti.erp.app.db.MIGRATION_25_26 -import de.gematik.ti.erp.app.db.MIGRATION_2_3 -import de.gematik.ti.erp.app.db.MIGRATION_3_4 -import de.gematik.ti.erp.app.db.MIGRATION_4_5 -import de.gematik.ti.erp.app.db.MIGRATION_5_6 -import de.gematik.ti.erp.app.db.MIGRATION_6_7 -import de.gematik.ti.erp.app.db.MIGRATION_7_8 -import de.gematik.ti.erp.app.db.MIGRATION_8_9 -import de.gematik.ti.erp.app.db.MIGRATION_9_10 -import de.gematik.ti.erp.app.db.converter.TruststoreConverter -import net.sqlcipher.database.SQLiteDatabase -import net.sqlcipher.database.SupportFactory -import java.util.UUID -import javax.inject.Qualifier -import javax.inject.Singleton - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class RoomDatabaseSecureSharedPreferences - -@Module -@InstallIn(SingletonComponent::class) -object RoomModule { - - val migrations = arrayOf( - MIGRATION_1_2, - MIGRATION_2_3, - MIGRATION_3_4, - MIGRATION_4_5, - MIGRATION_5_6, - MIGRATION_6_7, - MIGRATION_7_8, - MIGRATION_8_9, - MIGRATION_9_10, - MIGRATION_10_11, - MIGRATION_11_12, - MIGRATION_12_13, - MIGRATION_13_14, - MIGRATION_14_15, - MIGRATION_15_16, - MIGRATION_16_17, - MIGRATION_17_18, - MIGRATION_18_19, - MIGRATION_19_20, - MIGRATION_20_21, - MIGRATION_21_22, - MIGRATION_22_23, - MIGRATION_23_24, - MIGRATION_24_25, - MIGRATION_25_26 - ) - - private const val ENCRYPTED_PREFS_FILE_NAME = "ENCRYPTED_PREFS_FILE_NAME" - private const val ENCRYPTED_PREFS_PASSWORD_KEY = "ENCRYPTED_PREFS_PASSWORD_KEY" - private const val MASTER_KEY_ALIAS = "ROOM_DB_MASTER_KEY" - - @Singleton - @Provides - fun provideRoomDatabase( - @ApplicationContext context: Context, - truststoreConverter: TruststoreConverter, - @RoomDatabaseSecureSharedPreferences securePrefs: SharedPreferences - ): AppDatabase { - val passphrase: ByteArray = - SQLiteDatabase.getBytes(getPassphrase(securePrefs).toCharArray()) - val factory = SupportFactory(passphrase) - return Room.databaseBuilder( - context.applicationContext, - AppDatabase::class.java, - "db" - ) - .addMigrations(*migrations) - .addTypeConverter(truststoreConverter) - .openHelperFactory(factory) - .build() - } - - private fun getPassphrase(sharedPreferences: SharedPreferences): String { - if (getPassword(sharedPreferences).isNullOrEmpty()) { - val passPhrase = generatePassPhrase() - storePassPhrase(sharedPreferences, passPhrase) - } - return getPassword(sharedPreferences) - ?: throw IllegalStateException("passphrase should not be empty") - } - - private fun generatePassPhrase(): String { - return UUID.randomUUID().toString() - } - - private fun storePassPhrase( - @RoomDatabaseSecureSharedPreferences sharedPreferences: SharedPreferences, - passPhrase: String - ) { - sharedPreferences.edit().putString( - ENCRYPTED_PREFS_PASSWORD_KEY, - passPhrase - ) - .apply() - } - - private fun getPassword(sharedPreferences: SharedPreferences): String? { - return sharedPreferences.getString( - ENCRYPTED_PREFS_PASSWORD_KEY, - null - ) - } - - @RoomDatabaseSecureSharedPreferences - @Singleton - @Provides - fun providesSecureSharedPreferences(@ApplicationContext context: Context): SharedPreferences { - return EncryptedSharedPreferences.create( - context, - ENCRYPTED_PREFS_FILE_NAME, - MasterKey.Builder(context, MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/TruststoreModule.kt b/android/src/main/java/de/gematik/ti/erp/app/di/TruststoreModule.kt deleted file mode 100644 index 27287dd4..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/di/TruststoreModule.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import com.squareup.moshi.Moshi -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.db.converter.TruststoreConverter -import de.gematik.ti.erp.app.vau.VauCryptoConfig -import de.gematik.ti.erp.app.vau.api.model.OCSPAdapter -import de.gematik.ti.erp.app.vau.api.model.X509Adapter -import de.gematik.ti.erp.app.vau.interceptor.DefaultCryptoConfig -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -abstract class TruststoreAbstractModule { - @Singleton - @Binds - abstract fun bindCryptoConfig( - defaultCryptoConfig: DefaultCryptoConfig - ): VauCryptoConfig -} - -@Module -@InstallIn(SingletonComponent::class) -object TruststoreModule { - @TruststoreMoshi - @Provides - fun provideTruststoreMoshi(): Moshi = Moshi.Builder().add(OCSPAdapter()).add(X509Adapter()).build() - - @Provides - fun providesRoomConverter(@TruststoreMoshi moshi: Moshi) = TruststoreConverter(moshi) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt b/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt index 12e36f9f..ae1fb585 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/featuretoggle/FeatureToggleManager.kt @@ -23,18 +23,15 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.coroutines.flow.map private val Context.dataStore by preferencesDataStore("featureToggles") enum class Features(val featureName: String) { - FAST_TRACK("FastTrack"), - ADD_PROFILE("AddProfiles") + REDEEM_WITHOUT_TI("RedeemWithoutTI") } -class FeatureToggleManager @Inject constructor(@ApplicationContext val context: Context) { +class FeatureToggleManager(val context: Context) { private val dataStore = context.dataStore val features = Features.values() diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/IdpModule.kt b/android/src/main/java/de/gematik/ti/erp/app/idp/IdpModule.kt new file mode 100644 index 00000000..ca5c7ff6 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/idp/IdpModule.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp + +import de.gematik.ti.erp.app.di.NetworkSecurePreferencesTag +import de.gematik.ti.erp.app.idp.repository.IdpLocalDataSource +import de.gematik.ti.erp.app.idp.repository.IdpPairingRepository +import de.gematik.ti.erp.app.idp.repository.IdpRemoteDataSource +import de.gematik.ti.erp.app.idp.repository.IdpRepository +import de.gematik.ti.erp.app.idp.usecase.IdpAlternateAuthenticationUseCase +import de.gematik.ti.erp.app.idp.usecase.IdpBasicUseCase +import de.gematik.ti.erp.app.idp.usecase.IdpCryptoProvider +import de.gematik.ti.erp.app.idp.usecase.IdpDeviceInfoProvider +import de.gematik.ti.erp.app.idp.usecase.IdpPreferenceProvider +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +val idpModule = DI.Module("idpModule") { + bindProvider { IdpLocalDataSource(instance()) } + bindProvider { IdpPairingRepository(instance()) } + bindProvider { IdpRemoteDataSource(instance()) } + bindProvider { IdpAlternateAuthenticationUseCase(instance(), instance(), instance()) } + bindProvider { IdpCryptoProvider() } + bindProvider { IdpDeviceInfoProvider() } + bindProvider { + IdpPreferenceProvider().apply { + sharedPreferences = instance(NetworkSecurePreferencesTag) + } + } + bindSingleton { IdpRepository(instance(), instance()) } + bindSingleton { IdpBasicUseCase(instance(), instance()) } + bindSingleton { + IdpUseCase( + repository = instance(), + pairingRepository = instance(), + altAuthUseCase = instance(), + profilesRepository = instance(), + basicUseCase = instance(), + preferences = instance(), + cryptoProvider = instance() + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/AuthenticationData.kt b/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/AuthenticationData.kt deleted file mode 100644 index c947f2ae..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/AuthenticationData.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.idp.api.models - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -/** - * Device type. `gemF_Biometrie 4.1.2.2` - */ -@JsonClass(generateAdapter = true) -data class DeviceType( - @Json(name = "device_type_data_version") val version: String = "1.0", - @Json(name = "manufacturer") val manufacturer: String, - @Json(name = "product") val productName: String, - @Json(name = "model") val model: String, - @Json(name = "os") val operatingSystem: String, - @Json(name = "os_version") val operatingSystemVersion: String, -) - -/** - * Device information. `gemF_Biometrie 4.1.2.3` - */ -@JsonClass(generateAdapter = true) -data class DeviceInformation( - @Json(name = "device_information_data_version") val version: String = "1.0", - @Json(name = "name") val name: String, // android device name set by user - @Json(name = "device_type") val deviceType: DeviceType, -) - -/** - * Pairing data. `gemF_Biometrie 4.1.2.4` - */ -@JsonClass(generateAdapter = true) -data class PairingData( - @Json(name = "pairing_data_version") val version: String = "1.0", - - @Json(name = "se_subject_public_key_info") val subjectPublicKeyInfoOfSecureElement: String, - @Json(name = "key_identifier") val keyAliasOfSecureElement: String, // alias of the keystore entry - @Json(name = "product") val productName: String, - - @Json(name = "serialnumber") val serialNumberOfHealthCard: String, - @Json(name = "issuer") val issuerOfHealthCard: String, - @Json(name = "not_after") val validityUntilOfHealthCard: Long, - - @Json(name = "auth_cert_subject_public_key_info") val subjectPublicKeyInfoOfHealthCard: String, -) - -/** - * Registration data. `gemF_Biometrie 4.1.2.6` - */ -@JsonClass(generateAdapter = true) -data class RegistrationData( - @Json(name = "registration_data_version") val version: String = "1.0", - @Json(name = "signed_pairing_data") val signedPairingData: String, - @Json(name = "auth_cert") val healthCardCertificate: String, - @Json(name = "device_information") val deviceInformation: DeviceInformation, -) - -/** - * Authentication data. `gemF_Biometrie 4.1.2.8` - */ -@JsonClass(generateAdapter = true) -data class AuthenticationData( - @Json(name = "authentication_data_version") val version: String = "1.0", - @Json(name = "challenge_token") val challenge: String, - @Json(name = "auth_cert") val healthCardCertificate: String, - @Json(name = "key_identifier") val keyAliasOfSecureElement: String, // alias of the keystore entry - @Json(name = "device_information") val deviceInformation: DeviceInformation, - @Json(name = "amr") val authenticationMethod: List, -) - -/** - * Pairing entry. `gemF_Biometrie 4.1.2.11` - */ -@JsonClass(generateAdapter = true) -data class PairingResponseEntry( - @Json(name = "pairing_entry_data_version") val version: String = "1.0", - @Json(name = "name") val name: String, // android device name set by user - @Json(name = "creation_time") val authCert: Long, - @Json(name = "signed_pairing_data") val signedPairingData: String, -) - -/** - * Pairing entries. `gemF_Biometrie 4.1.2.12` - */ -@JsonClass(generateAdapter = true) -data class PairingResponseEntries( - @Json(name = "pairing_entries") val entries: List, -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/BasicData.kt b/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/BasicData.kt deleted file mode 100644 index c918b8c1..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/BasicData.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.idp.api.models - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.jose4j.jwk.JsonWebKey -import org.jose4j.jwk.PublicJsonWebKey -import org.jose4j.jws.JsonWebSignature - -@JsonClass(generateAdapter = true) -data class IdpDiscoveryInfo( - @Json(name = "authorization_endpoint") val authorizationURL: String, - @Json(name = "sso_endpoint") val ssoURL: String, - @Json(name = "token_endpoint") val tokenURL: String, - @Json(name = "uri_pair") val pairingURL: String, - @Json(name = "auth_pair_endpoint") val authenticationURL: String, - @Json(name = "uri_puk_idp_enc") val uriPukIdpEnc: String, - @Json(name = "uri_puk_idp_sig") val uriPukIdpSig: String, - @Json(name = "exp") val expirationTime: Long, - @Json(name = "iat") val issuedAt: Long, - @Json(name = "kk_app_list_uri") val krankenkassenAppURL: String? = null, - @Json(name = "third_party_authorization_endpoint") val thirdPartyAuthorizationURL: String? = null -) - -@JsonClass(generateAdapter = true) -data class AuthenticationID( - @Json(name = "kk_app_name") val name: String, - @Json(name = "kk_app_id") val authenticationID: String -) -@JsonClass(generateAdapter = true) -data class AuthenticationIDList( - @Json(name = "kk_app_list") val authenticationIDList: List, -) - -@JsonClass(generateAdapter = true) -data class AuthorizationRedirectInfo( - @Json(name = "client_id") val clientId: String, - @Json(name = "state") val state: String, - @Json(name = "redirect_uri") val redirectUri: String, - @Json(name = "code_challenge") val codeChallenge: String, - @Json(name = "code_challenge_method") val codeChallengeMethod: String, - @Json(name = "response_type")val responseType: String, - @Json(name = "nonce")val nonce: String, - @Json(name = "scope")val scope: String -) - -@JvmInline -value class JWSPublicKey(val jws: PublicJsonWebKey) - -@JvmInline -value class JWSKey(val jws: JsonWebKey) - -data class JWSChallenge(val jws: JsonWebSignature, val raw: String) - -@JsonClass(generateAdapter = true) -data class Challenge( - val challenge: JWSChallenge -) - -@JsonClass(generateAdapter = true) -data class TokenResponse( - @Json(name = "access_token") val accessToken: String, - @Json(name = "expires_in") val expiresIn: Long, - @Json(name = "id_token") val idToken: String, - @Json(name = "sso_token") val ssoToken: String?, - @Json(name = "token_type") val tokenType: String -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpLocalDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpLocalDataSource.kt deleted file mode 100644 index 5a6480cc..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpLocalDataSource.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.idp.repository - -import androidx.room.withTransaction -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.IdpAuthenticationDataEntity -import de.gematik.ti.erp.app.db.entities.IdpConfiguration -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterNotNull -import java.time.Instant -import javax.inject.Inject - -class IdpLocalDataSource @Inject constructor( - private val db: AppDatabase -) { - suspend fun saveIdpInfo(idpConfiguration: IdpConfiguration) { - db.idpInfoDao().insertIdpConfiguration(idpConfiguration) - } - - suspend fun loadIdpInfo(): IdpConfiguration? { - return db.idpInfoDao().getIdpConfiguration() - } - - suspend fun clearIdpInfo() { - db.idpInfoDao().clearIdpConfigurationTable() - } - - suspend fun saveSingleSignOnToken( - profileName: String, - token: String?, - scope: IdpAuthenticationDataEntity.SingleSignOnTokenScope?, - validOn: Instant?, - expiresOn: Instant? - ) { - db.idpAuthDataDao().updateToken( - profileName = profileName, - token = token, - scope = scope, - validOn = validOn, - expiresOn = expiresOn - ) - } - - suspend fun saveSingleSignOnToken( - profileName: String, - token: String?, - validOn: Instant?, - expiresOn: Instant? - ) { - db.idpAuthDataDao().updateTokenWithoutScope( - profileName = profileName, - token = token, - validOn = validOn, - expiresOn = expiresOn - ) - } - - suspend fun saveHealthCardCertificate(profileName: String, cert: ByteArray) { - db.idpAuthDataDao().updateHealthCardCert(profileName, cert) - } - - suspend fun saveSecureElementAlias(profileName: String, alias: ByteArray) { - db.idpAuthDataDao().updateAliasOfSecureElement(profileName, alias) - } - - suspend fun loadIdpAuthData(profileName: String): Flow { - db.withTransaction { - if (db.profileDao().countProfilesWithName(profileName) == 1) { - db.idpAuthDataDao().insert(IdpAuthenticationDataEntity(profileName)) - } - } - - return db.idpAuthDataDao().getIdpAuthenticationEntity(profileName).filterNotNull() - } - - suspend fun clearIdpAuthData(profileName: String) { - db.idpAuthDataDao().clear(profileName) - } - - suspend fun setCardAccessNumber(profileName: String, can: String?) { - db.idpAuthDataDao().updateCardAccessNumber(profileName, can) - } - - fun cardAccessNumber(profileName: String) = - db.idpAuthDataDao().cardAccessNumber(profileName) - - suspend fun updateLastAuthenticated(lastAuthenticated: Instant, profileName: String) { - db.profileDao().updateLastAuthenticated(lastAuthenticated, profileName) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt deleted file mode 100644 index 5cab2b01..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.idp.repository - -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.db.entities.IdpAuthenticationDataEntity -import de.gematik.ti.erp.app.db.entities.IdpConfiguration -import de.gematik.ti.erp.app.idp.api.REDIRECT_URI -import de.gematik.ti.erp.app.idp.api.models.AuthenticationID -import de.gematik.ti.erp.app.idp.api.models.AuthenticationIDList -import de.gematik.ti.erp.app.idp.api.models.AuthorizationRedirectInfo -import de.gematik.ti.erp.app.idp.api.models.Challenge -import de.gematik.ti.erp.app.idp.api.models.IdpDiscoveryInfo -import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntry -import de.gematik.ti.erp.app.idp.usecase.IdpNonce -import de.gematik.ti.erp.app.idp.usecase.IdpState -import de.gematik.ti.erp.app.idp.usecase.IdpUseCase -import de.gematik.ti.erp.app.vau.extractECPublicKey -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import org.bouncycastle.cert.X509CertificateHolder -import org.jose4j.base64url.Base64 -import org.jose4j.jws.JsonWebSignature -import org.jose4j.jwx.JsonWebStructure -import java.security.KeyStore -import java.security.PublicKey -import java.time.Duration -import java.time.Instant -import javax.inject.Inject -import javax.inject.Singleton - -private const val ssoTokenPrefKey = "ssoToken" // TODO remove within migration -private const val cardAccessNumberPrefKey = "cardAccessNumber" - -@JvmInline -value class JWSDiscoveryDocument(val jws: JsonWebSignature) - -sealed class SingleSignOnToken { - abstract val expiresOn: Instant - abstract val validOn: Instant - - fun isValid(instant: Instant = Instant.now()) = - instant < expiresOn && instant >= validOn - - fun tokenOrNull(): String? = - when (this) { - is AlternateAuthenticationToken -> this.token - is AlternateAuthenticationWithoutToken -> null - is DefaultToken -> this.token - } - - data class DefaultToken( - val token: String, - override val expiresOn: Instant = extractExpirationTimestamp(token), - override val validOn: Instant = extractValidOnTimestamp(token), - ) : SingleSignOnToken() - - data class AlternateAuthenticationToken( - val token: String, - override val expiresOn: Instant = extractExpirationTimestamp(token), - override val validOn: Instant = extractValidOnTimestamp(token), - ) : SingleSignOnToken() - - data class AlternateAuthenticationWithoutToken( - override val expiresOn: Instant = Instant.MIN, - override val validOn: Instant = Instant.MIN, - ) : SingleSignOnToken() -} - -fun extractExpirationTimestamp(ssoToken: String): Instant = - Instant.ofEpochSecond( - JsonWebStructure - .fromCompactSerialization(ssoToken) - .headers - .getLongHeaderValue("exp") - ) - -fun extractValidOnTimestamp(ssoToken: String): Instant = - extractExpirationTimestamp(ssoToken) - Duration.ofHours(24) - -@Singleton -class IdpRepository @Inject constructor( - moshi: Moshi, - private val remoteDataSource: IdpRemoteDataSource, - private val localDataSource: IdpLocalDataSource -) { - private val discoveryDocumentBodyAdapter = moshi.adapter(IdpDiscoveryInfo::class.java) - private val authenticationIDAdapter = moshi.adapter(AuthenticationIDList::class.java) - private val authorizationRedirectInfoAdapter = - moshi.adapter(AuthorizationRedirectInfo::class.java) - - val decryptedAccessTokenMap: MutableStateFlow> = MutableStateFlow(mutableMapOf()) - - fun decryptedAccessToken(profileName: String) = - decryptedAccessTokenMap.map { it[profileName] }.distinctUntilChanged() - - suspend fun setCardAccessNumber(profileName: String, can: String?) { - require(can?.isNotEmpty() ?: true) - localDataSource.setCardAccessNumber(profileName, can) - } - - fun updateDecryptedAccessTokenMap(currentName: String, updatedName: String) { - decryptedAccessTokenMap.update { - val token = it[currentName] - it - currentName + (updatedName to token) - } - } - - fun cardAccessNumber(profileName: String) = - localDataSource.cardAccessNumber(profileName) - - suspend fun getSingleSignOnToken(profileName: String) = localDataSource.loadIdpAuthData(profileName).map { entity -> - when (entity.singleSignOnTokenScope) { - IdpAuthenticationDataEntity.SingleSignOnTokenScope.Default -> - entity.singleSignOnToken?.let { token -> - SingleSignOnToken.DefaultToken( - token = token, - expiresOn = entity.singleSignOnTokenExpiresOn - ?: extractExpirationTimestamp(token), // scope & token present; this must be not null - validOn = entity.singleSignOnTokenValidOn - ?: extractValidOnTimestamp(token), // scope & token present; this must be not null - ) - } - IdpAuthenticationDataEntity.SingleSignOnTokenScope.AlternateAuthentication -> - entity.singleSignOnToken?.let { token -> - SingleSignOnToken.AlternateAuthenticationToken( - token = token, - expiresOn = entity.singleSignOnTokenExpiresOn - ?: extractExpirationTimestamp(token), // scope & token present; this must be not null - validOn = entity.singleSignOnTokenValidOn - ?: extractValidOnTimestamp(token), // scope & token present; this must be not null - ) - } ?: SingleSignOnToken.AlternateAuthenticationWithoutToken() - else -> null - } - } - - suspend fun setSingleSignOnToken(profileName: String, token: SingleSignOnToken) { - val actualToken = when (token) { - is SingleSignOnToken.AlternateAuthenticationToken -> token.token - is SingleSignOnToken.DefaultToken -> token.token - is SingleSignOnToken.AlternateAuthenticationWithoutToken -> null - } - - val actualTokenScope = when (token) { - is SingleSignOnToken.AlternateAuthenticationWithoutToken, - is SingleSignOnToken.AlternateAuthenticationToken -> - IdpAuthenticationDataEntity.SingleSignOnTokenScope.AlternateAuthentication - is SingleSignOnToken.DefaultToken -> - IdpAuthenticationDataEntity.SingleSignOnTokenScope.Default - } - - localDataSource.saveSingleSignOnToken( - profileName = profileName, - token = actualToken, - scope = actualTokenScope, - validOn = token.validOn, - expiresOn = token.expiresOn - ) - if (token.isValid()) { - localDataSource.updateLastAuthenticated(token.validOn, profileName) - } - } - - suspend fun getHealthCardCertificate(profileName: String) = - localDataSource.loadIdpAuthData(profileName).map { it.healthCardCertificate } - - suspend fun setHealthCardCertificate(profileName: String, cert: ByteArray) = - localDataSource.saveHealthCardCertificate(profileName, cert) - - suspend fun getSingleSignOnTokenScope(profileName: String) = - localDataSource.loadIdpAuthData(profileName).map { it.singleSignOnTokenScope } - - suspend fun getAliasOfSecureElementEntry(profileName: String) = - localDataSource.loadIdpAuthData(profileName).map { it.aliasOfSecureElementEntry } - - suspend fun setAliasOfSecureElementEntry(profileName: String, alias: ByteArray) { - require(alias.size == 32) - localDataSource.saveSecureElementAlias(profileName, alias) - } - - suspend fun fetchChallenge( - url: String, - codeChallenge: String, - state: String, - nonce: String, - isDeviceRegistration: Boolean = false - ): Result = - remoteDataSource.fetchChallenge(url, codeChallenge, state, nonce, isDeviceRegistration) - - /** - * Returns an unchecked and possible invalid idp configuration parsed from the discovery document. - */ - suspend fun loadUncheckedIdpConfiguration(): IdpConfiguration { - return localDataSource.loadIdpInfo() ?: run { - when (val r = remoteDataSource.fetchDiscoveryDocument()) { - is Result.Error -> throw r.exception - is Result.Success -> extractUncheckedIdpConfiguration(r.data).also { - localDataSource.saveIdpInfo( - it - ) - } - } - } - } - - suspend fun postSignedChallenge(url: String, signedChallenge: String): Result = - remoteDataSource.postChallenge(url, signedChallenge) - - suspend fun postUnsignedChallengeWithSso( - url: String, - ssoToken: String, - unsignedChallenge: String - ): Result = - remoteDataSource.postChallenge(url, ssoToken, unsignedChallenge) - - suspend fun postToken( - url: String, - keyVerifier: String, - code: String, - redirectUri: String = REDIRECT_URI - ) = - remoteDataSource.postToken( - url, - keyVerifier = keyVerifier, - code = code, - redirectUri = redirectUri - ) - - suspend fun fetchExternalAuthorizationIDList( - url: String, - idpPukSigKey: PublicKey, - ): List { - val jwtResult = remoteDataSource.fetchExternalAuthorizationIDList(url) - if (jwtResult is Result.Success) { - return extractAuthenticationIDList(jwtResult.data.apply { key = idpPukSigKey }.payload) - } else { - error("couldn't extract authentication ID List") - } - } - - suspend fun fetchIdpPukSig(url: String) = - remoteDataSource.fetchIdpPukSig(url) - - suspend fun fetchIdpPukEnc(url: String) = - remoteDataSource.fetchIdpPukEnc(url) - - private fun parseDiscoveryDocumentBody(body: String): IdpDiscoveryInfo = - requireNotNull(discoveryDocumentBodyAdapter.fromJson(body)) { "Couldn't parse discovery document" } - - fun extractAuthenticationIDList(payload: String): List { - // TODO: check certificate - return requireNotNull(authenticationIDAdapter.fromJson(payload)) { "Couldn't parse Authentication List" }.authenticationIDList - } - - fun extractAuthorizationRedirectInfo(payload: String): AuthorizationRedirectInfo { - // TODO: check certificate - return requireNotNull(authorizationRedirectInfoAdapter.fromJson(payload)) { "Couldn't parse AuthorizationRedirectInfo" } - } - - fun extractUncheckedIdpConfiguration(discoveryDocument: JWSDiscoveryDocument): IdpConfiguration { - val x5c = requireNotNull( - (discoveryDocument.jws.headers?.getObjectHeaderValue("x5c") as? ArrayList<*>)?.firstOrNull() as? String - ) { "Missing certificate" } - val certificateHolder = X509CertificateHolder(Base64.decode(x5c)) - - discoveryDocument.jws.key = certificateHolder.extractECPublicKey() - - val discoveryDocumentBody = parseDiscoveryDocumentBody(discoveryDocument.jws.payload) - - return IdpConfiguration( - authorizationEndpoint = overwriteEndpoint(discoveryDocumentBody.authorizationURL), - ssoEndpoint = overwriteEndpoint(discoveryDocumentBody.ssoURL), - tokenEndpoint = overwriteEndpoint(discoveryDocumentBody.tokenURL), - pairingEndpoint = discoveryDocumentBody.pairingURL, - authenticationEndpoint = overwriteEndpoint(discoveryDocumentBody.authenticationURL), - pukIdpEncEndpoint = overwriteEndpoint(discoveryDocumentBody.uriPukIdpEnc), - pukIdpSigEndpoint = overwriteEndpoint(discoveryDocumentBody.uriPukIdpSig), - expirationTimestamp = convertTimeStampTo(discoveryDocumentBody.expirationTime), - issueTimestamp = convertTimeStampTo(discoveryDocumentBody.issuedAt), - certificate = certificateHolder, - externalAuthorizationIDsEndpoint = overwriteEndpoint(discoveryDocumentBody.krankenkassenAppURL), - thirdPartyAuthorizationEndpoint = overwriteEndpoint(discoveryDocumentBody.thirdPartyAuthorizationURL) - ) - } - - private fun convertTimeStampTo(timeStamp: Long) = - Instant.ofEpochSecond(timeStamp) - - private fun overwriteEndpoint(oldEndpoint: String?) = - oldEndpoint?.replace(".zentral.idp.splitdns.ti-dienste.de", ".app.ti-dienste.de") ?: "" - - suspend fun postPairing( - url: String, - encryptedRegistrationData: String, - token: String - ): Result = - remoteDataSource.postPairing( - url, - token = token, - encryptedRegistrationData = encryptedRegistrationData - ) - - suspend fun postBiometricAuthenticationData( - url: String, - encryptedSignedAuthenticationData: String - ): Result = - remoteDataSource.authorizeBiometric(url, encryptedSignedAuthenticationData) - - suspend fun postExternAppAuthorizationData( - url: String, - externalAuthorizationData: IdpUseCase.ExternalAuthorizationData - ): Result = - remoteDataSource.authorizeExtern( - url = url, - externalAuthorizationData = externalAuthorizationData - ) - - suspend fun invalidate(profileName: String) { - try { - getAliasOfSecureElementEntry(profileName).first()?.also { - KeyStore.getInstance("AndroidKeyStore") - .apply { load(null) } - .deleteEntry(it.decodeToString()) - } - } catch (e: Exception) { - // silent fail; expected - } - invalidateConfig() - invalidateDecryptedAccessToken(profileName) - localDataSource.clearIdpAuthData(profileName) - } - - suspend fun invalidateConfig() { - localDataSource.clearIdpInfo() - } - - suspend fun invalidateWithUserCredentials(profileName: String) { - invalidate(profileName) - setCardAccessNumber(profileName, null) - } - - suspend fun invalidateSingleSignOnTokenRetainingScope(profileName: String) = - localDataSource.saveSingleSignOnToken(profileName = profileName, token = null, validOn = null, expiresOn = null) - - fun invalidateDecryptedAccessToken(profileName: String) { - decryptedAccessTokenMap.update { - it - profileName - } - } - - suspend fun getAuthorizationRedirect( - url: String, - state: IdpState, - codeChallenge: String, - nonce: IdpNonce, - kkAppId: String - ): String { - val result = remoteDataSource.requestAuthorizationRedirect( - url = url, externalAppId = kkAppId, - codeChallenge = codeChallenge, - nonce = nonce.nonce, - state = state.state - ) - if (result is Result.Success) { - return result.data - } else { - throw (result as Result.Error).exception - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt deleted file mode 100644 index aadc6c3e..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.idp.usecase - -import android.annotation.SuppressLint -import android.content.SharedPreferences -import android.net.Uri -import de.gematik.ti.erp.app.api.ApiCallException -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.di.NetworkSecureSharedPreferences -import de.gematik.ti.erp.app.idp.api.EXT_AUTH_REDIRECT_URI -import de.gematik.ti.erp.app.idp.api.IdpService -import de.gematik.ti.erp.app.idp.api.models.AuthenticationID -import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken -import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository -import de.gematik.ti.erp.app.vau.extractECPublicKey -import kotlinx.coroutines.sync.withLock -import java.io.IOException -import java.security.KeyStore -import java.security.PrivateKey -import java.security.PublicKey -import java.security.Signature -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Mutex -import timber.log.Timber - -/** - * Exception thrown by [IdpUseCase.loadAccessToken]. - */ -class RefreshFlowException : IOException { - /** - * Is true if the sso token is not valid anymore and the user is required to authenticate again. - */ - val userActionRequired: Boolean - val ssoToken: SingleSignOnToken? - - constructor( - userActionRequired: Boolean, - ssoToken: SingleSignOnToken?, - cause: Throwable - ) : super(cause) { - this.userActionRequired = userActionRequired - this.ssoToken = ssoToken - } - - constructor( - userActionRequired: Boolean, - ssoToken: SingleSignOnToken?, - message: String - ) : super(message) { - this.userActionRequired = userActionRequired - this.ssoToken = ssoToken - } -} - -class AltAuthenticationCryptoException(cause: Throwable) : IllegalStateException(cause) - -private const val EXT_AUTH_CODE_CHALLENGE: String = "EXT_AUTH_CODE_CHALLENGE" -private const val EXT_AUTH_CODE_VERIFIER: String = "EXT_AUTH_CODE_VERIFIER" -private const val EXT_AUTH_STATE: String = "EXT_AUTH_STATE" -private const val EXT_AUTH_NONCE: String = "EXT_AUTH_NONCE" - -@Singleton -class IdpUseCase @Inject constructor( - private val repository: IdpRepository, - private val altAuthUseCase: IdpAlternateAuthenticationUseCase, - private val profilesRepository: ProfilesRepository, - private val basicUseCase: IdpBasicUseCase, - @NetworkSecureSharedPreferences - private val sharedPreferences: SharedPreferences -) { - private val lock = Mutex() - - /** - * If no bearer token is set or [refresh] is true, this will trigger [IdpBasicUseCase.refreshAccessTokenWithSsoFlow]. - */ - suspend fun loadAccessToken(refresh: Boolean = false, profileName: String): String = lock.withLock { - val ssoToken = repository.getSingleSignOnToken(profileName).first() - - when (ssoToken) { - null, - is SingleSignOnToken.AlternateAuthenticationWithoutToken -> { - repository.invalidateDecryptedAccessToken(profileName) - throw RefreshFlowException( - true, - ssoToken, - "SSO token not set for $profileName!" - ) - } - is SingleSignOnToken.AlternateAuthenticationToken, - is SingleSignOnToken.DefaultToken -> { - val accToken = repository.decryptedAccessTokenMap.value[profileName] - - if (refresh || accToken == null) { - repository.invalidateDecryptedAccessToken(profileName) - - val actualToken = when (ssoToken) { - is SingleSignOnToken.AlternateAuthenticationToken -> ssoToken.token - is SingleSignOnToken.DefaultToken -> ssoToken.token - else -> error("Unknown token scope") - } - - val initialData = basicUseCase.initializeConfigurationAndKeys() - try { - val refreshData = basicUseCase.refreshAccessTokenWithSsoFlow( - initialData, - scope = IdpScope.Default, - ssoToken = actualToken - ) - refreshData.accessToken - } catch (e: Exception) { - Timber.e(e, "Couldn't refresh access token") - (e as? ApiCallException)?.also { - when (it.response.code()) { - // 400 returned by redirect call if sso token is not valid anymore - 400, 401, 403 -> { - repository.invalidateSingleSignOnTokenRetainingScope(profileName) - throw RefreshFlowException(true, ssoToken, e) - } - } - } - throw RefreshFlowException(false, null, e) - } - } else { - accToken - } - .also { - repository.decryptedAccessTokenMap.update { decryptedAccessTokenMap -> - decryptedAccessTokenMap + (profileName to it) - } - } - } - } - } - - /** - * Initial flow fetching the sso & access token requiring the health card to sign the challenge. - */ - suspend fun authenticationFlowWithHealthCard( - healthCardCertificate: suspend () -> ByteArray, - sign: suspend (hash: ByteArray) -> ByteArray - ) = lock.withLock { - val initialData = basicUseCase.initializeConfigurationAndKeys() - val challengeData = - basicUseCase.challengeFlow(initialData, scope = IdpScope.Default) - val activeProfileName = getActiveProfileName() - val basicData = basicUseCase.basicAuthFlow( - initialData = initialData, - challengeData = challengeData, - healthCardCertificate = healthCardCertificate(), - sign = sign - ) - val ssoToken = SingleSignOnToken.DefaultToken( - token = basicData.ssoToken - ) - profilesRepository.setInsuranceInformation(activeProfileName, basicData.idTokenInsurantName, basicData.idTokenInsuranceIdentifier, basicData.idTokenInsuranceName) - repository.setSingleSignOnToken(activeProfileName, ssoToken) - repository.decryptedAccessTokenMap.update { decryptedAccessTokenMap -> - decryptedAccessTokenMap + (activeProfileName to basicData.accessToken) - } - } - - private suspend fun getActiveProfileName() = - profilesRepository.activeProfile().map { it.profileName }.first() - - /** - * Get all the information for the correct endpoints from the discovery document and request - * the external Health Insurance Companies which are capable of authenticate you with their app - */ - suspend fun downloadDiscoveryDocumentAndGetExternAuthenticatorIDs(): List { - val initialData = basicUseCase.initializeConfigurationAndKeys() - return repository.fetchExternalAuthorizationIDList( - initialData.config.externalAuthorizationIDsEndpoint ?: error("Fasttrack is not available"), - idpPukSigKey = initialData.config.certificate.extractECPublicKey() - ) - } - - /** - * With chosen Health Insurance Company, request IDP for Authentication information, - * sent as a redirect which is supposed to be fired as an Intent - * @param externalAuthorizationID identifier of the health insurance company - */ - @SuppressLint("ApplySharedPref") - suspend fun getUniversalLinkForExternalAuthorization(externalAuthorizationID: String): Uri { - val initialData = basicUseCase.initializeConfigurationAndKeys() - - val redirectUri = repository.getAuthorizationRedirect( - url = initialData.config.thirdPartyAuthorizationEndpoint ?: error("Fasttrack is not available"), - state = initialData.state, - codeChallenge = initialData.codeChallenge, - nonce = initialData.nonce, - kkAppId = externalAuthorizationID - ) - - val parsedUri = Uri.parse(redirectUri) - - sharedPreferences.edit() - .putString(EXT_AUTH_STATE, parsedUri.getQueryParameter("state")) - .putString(EXT_AUTH_NONCE, initialData.nonce.nonce) - .putString(EXT_AUTH_CODE_VERIFIER, initialData.codeVerifier) - .putString(EXT_AUTH_CODE_CHALLENGE, initialData.codeChallenge).commit() - - return parsedUri - } - - class ExternalAuthorizationData(uri: Uri) { - val code = IdpService.extractQueryParameter(uri, "code") - val state = IdpService.extractQueryParameter(uri, "state") - val kkAppRedirectUri = IdpService.extractQueryParameter(uri, "kk_app_redirect_uri") - } - - suspend fun authenticateWithExternalAppAuthorization(uri: Uri) { - - val externalAuthorizationData = ExternalAuthorizationData(uri) - - require(externalAuthorizationData.state == sharedPreferences.getString(EXT_AUTH_STATE, "")) - - val initialData = basicUseCase.initializeConfigurationAndKeys() - val redirectStringResult = repository.postExternAppAuthorizationData( - url = initialData.config.thirdPartyAuthorizationEndpoint ?: error("Fasttrack is not available"), - externalAuthorizationData = externalAuthorizationData - ) - if (redirectStringResult is Result.Error) { - error(redirectStringResult.exception) - } - val redirect = Uri.parse((redirectStringResult as Result.Success).data) - - val redirectCodeJwe = IdpService.extractQueryParameter(redirect, "code") - val redirectSsoToken = IdpService.extractQueryParameter(redirect, "ssotoken") - - val idpTokenResult = basicUseCase.postCodeAndDecryptAccessToken( - url = initialData.config.tokenEndpoint, - nonce = IdpNonce(sharedPreferences.getString(EXT_AUTH_NONCE, "")!!), - codeVerifier = sharedPreferences.getString(EXT_AUTH_CODE_VERIFIER, "")!!, - code = redirectCodeJwe, - pukEncKey = initialData.pukEncKey, - pukSigKey = initialData.pukSigKey, - redirectUri = EXT_AUTH_REDIRECT_URI - ) - val activeProfileName = getActiveProfileName() - - repository.setSingleSignOnToken( - activeProfileName, - SingleSignOnToken.DefaultToken(redirectSsoToken) - ) - repository.decryptedAccessTokenMap.update { decryptedAccessTokenMap -> - decryptedAccessTokenMap + (activeProfileName to idpTokenResult.decryptedAccessToken) - } - } - - /** - * Pairing flow fetching the sso & access token requiring the health card and generated key material. - */ - suspend fun alternatePairingFlowWithSecureElement( - publicKeyOfSecureElementEntry: PublicKey, - aliasOfSecureElementEntry: ByteArray, - healthCardCertificate: suspend () -> ByteArray, - signWithHealthCard: suspend (hash: ByteArray) -> ByteArray - ) = lock.withLock { - val initialData = basicUseCase.initializeConfigurationAndKeys() - val challengeData = - basicUseCase.challengeFlow( - initialData, - scope = IdpScope.BiometricPairing - ) - val healthCardCert = healthCardCertificate() - val basicData = basicUseCase.basicAuthFlow( - initialData = initialData, - challengeData = challengeData, - healthCardCertificate = healthCardCert, - sign = signWithHealthCard - ) - - altAuthUseCase.registerDeviceWithHealthCard( - initialData = initialData, - accessToken = basicData.accessToken, - healthCardCertificate = healthCardCert, - publicKeyOfSecureElementEntry = publicKeyOfSecureElementEntry, - aliasOfSecureElementEntry = aliasOfSecureElementEntry, - signWithHealthCard = signWithHealthCard, - ) - val activeProfileName = getActiveProfileName() - profilesRepository.setInsuranceInformation(activeProfileName, basicData.idTokenInsurantName, basicData.idTokenInsuranceIdentifier, basicData.idTokenInsuranceName) - repository.setHealthCardCertificate(activeProfileName, healthCardCert) - // set pairing scope - repository.setSingleSignOnToken(activeProfileName, SingleSignOnToken.AlternateAuthenticationWithoutToken()) - repository.setAliasOfSecureElementEntry(activeProfileName, aliasOfSecureElementEntry) - } - - /** - * Actual authentication with secure element key material. Just like the [authenticationFlowWithHealthCard] it - * sets the sso & access token within the repository. - */ - suspend fun alternateAuthenticationFlowWithSecureElement(profileName: String) = lock.withLock { - val healthCardCertificate = - requireNotNull(repository.getHealthCardCertificate(profileName).first()) { "Health card certificate not set! Maybe you forgot to call alternatePairingFlowWithSecureElement before." } - val aliasOfSecureElementEntry = - requireNotNull(repository.getAliasOfSecureElementEntry(profileName).first()) { "Alias of secure element entry not set! Maybe you forgot to call alternatePairingFlowWithSecureElement before." } - - lateinit var privateKeyOfSecureElementEntry: PrivateKey - lateinit var signatureObjectOfSecureElementEntry: Signature - - try { - privateKeyOfSecureElementEntry = ( - KeyStore.getInstance("AndroidKeyStore") - .apply { load(null) } - .getEntry( - aliasOfSecureElementEntry.decodeToString(), - null - ) as KeyStore.PrivateKeyEntry - ).privateKey - signatureObjectOfSecureElementEntry = - Signature.getInstance("SHA256withECDSA", "AndroidKeyStoreBCWorkaround") - } catch (e: Exception) { - // the system might have removed the key during biometric reenrollment - // therefore their is no choice but to delete everything - repository.invalidate(profileName) - throw AltAuthenticationCryptoException(e) - } - - val initialData = basicUseCase.initializeConfigurationAndKeys() - val challengeData = basicUseCase.challengeFlow(initialData, scope = IdpScope.Default) - - val authData = altAuthUseCase.authenticateWithSecureElement( - initialData = initialData, - challenge = challengeData.challenge, - healthCardCertificate = healthCardCertificate, - authenticationMethod = IdpAlternateAuthenticationUseCase.AuthenticationMethod.Strong, - aliasOfSecureElementEntry = aliasOfSecureElementEntry, - privateKeyOfSecureElementEntry = privateKeyOfSecureElementEntry, - signatureObjectOfSecureElementEntry = signatureObjectOfSecureElementEntry, - ) - - profilesRepository.setInsuranceInformation(profileName, authData.idTokenInsurantName, authData.idTokenInsuranceIdentifier, authData.idTokenInsuranceName) - repository.setSingleSignOnToken( - profileName, - SingleSignOnToken.AlternateAuthenticationToken( - token = authData.ssoToken, - ) - ) - repository.decryptedAccessTokenMap.update { decryptedAccessTokenMap -> - decryptedAccessTokenMap + (profileName to authData.accessToken) - } - } - - suspend fun isCanAvailable() = - repository.cardAccessNumber(getActiveProfileName()).map { can -> can != null }.first() -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/interceptor/HeadersInterceptor.kt b/android/src/main/java/de/gematik/ti/erp/app/interceptor/HeadersInterceptor.kt index f9eafb0b..32b0a1c3 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/interceptor/HeadersInterceptor.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/interceptor/HeadersInterceptor.kt @@ -19,33 +19,40 @@ package de.gematik.ti.erp.app.interceptor import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.di.EndpointHelper import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import java.net.HttpURLConnection import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response -import timber.log.Timber +import io.github.aakira.napier.Napier -class BearerHeadersInterceptor( - private val idpUseCase: IdpUseCase, +private const val invalidAccessTokenHeader = "Www-Authenticate" +private const val invalidAccessTokenValue = "Bearer realm='prescriptionserver.telematik', error='invalACCESS_TOKEN'" + +class BearerHeaderInterceptor( + private val idpUseCase: IdpUseCase ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original: Request = chain.request() - val profileName = original.tag(String::class.java) - val response = chain.proceed(request(original, loadAccessToken(false, profileName))) - return if (response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { - Timber.d("Received 401 -> refresh access token") - chain.proceed(request(original, loadAccessToken(true, profileName))) + val profileId = original.tag(ProfileIdentifier::class.java) + val response = chain.proceed(request(original, loadAccessToken(false, profileId))) + return if (response.code == HttpURLConnection.HTTP_UNAUTHORIZED && + response.header(invalidAccessTokenHeader) == invalidAccessTokenValue + ) { + Napier.d("Received 401 -> refresh access token") + chain.proceed(request(original, loadAccessToken(true, profileId))) } else { response } } - private fun loadAccessToken(refresh: Boolean, profileName: String?) = + private fun loadAccessToken(refresh: Boolean, profileId: ProfileIdentifier?) = runBlocking { - idpUseCase.loadAccessToken(refresh, profileName ?: error("no profileName given")) + idpUseCase.loadAccessToken(refresh, profileId ?: error("no profile id given")) } private fun request(original: Request, token: String) = @@ -59,7 +66,7 @@ class BearerHeadersInterceptor( .build() } -class PharmacySearchInterceptor : Interceptor { +class PharmacySearchInterceptor(private val endpointHelper: EndpointHelper) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original: Request = chain.request() val request: Request = original.newBuilder() @@ -68,12 +75,20 @@ class PharmacySearchInterceptor : Interceptor { } } -class UserAgentHeaderInterceptor( - private val userAgent: String -) : Interceptor { +class UserAgentHeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("User-Agent", BuildKonfig.USER_AGENT) + .build() + + return chain.proceed(request) + } +} + +class ApiKeyHeaderInterceptor(private val endpointHelper: EndpointHelper) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() - .header("User-Agent", userAgent) + .header("X-Api-Key", endpointHelper.getErpApiKey()) .build() return chain.proceed(request) diff --git a/android/src/main/java/de/gematik/ti/erp/app/license/model/LicenseModels.kt b/android/src/main/java/de/gematik/ti/erp/app/license/model/LicenseModels.kt new file mode 100644 index 00000000..173842c5 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/license/model/LicenseModels.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.license.model + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +@Immutable +@Serializable +data class LicenseEntry( + val project: String, + val description: String? = null, + val version: String? = null, + val developers: List, + val url: String? = null, + val year: String? = null, + val licenses: List, + val dependency: String +) + +@Immutable +@Serializable +data class License( + val license: String, + @SerialName("license_url") + val licenseUrl: String +) + +fun parseLicenses(json: String): List = + Json.decodeFromString(json) diff --git a/android/src/main/java/de/gematik/ti/erp/app/license/ui/LicenseScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/license/ui/LicenseScreen.kt new file mode 100644 index 00000000..110cd2fe --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/license/ui/LicenseScreen.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.license.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.license.model.License +import de.gematik.ti.erp.app.license.model.LicenseEntry +import de.gematik.ti.erp.app.license.model.parseLicenses +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.ClickableTaggedText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.annotatedLinkStringLight + +const val LicenseFileUri = "open_source_licenses.json" + +@Composable +fun rememberLicenses(): List { + val context = LocalContext.current + return remember { + val json = context.assets.open(LicenseFileUri).bufferedReader().readText() + parseLicenses(json) + } +} + +@Composable +fun LicenseScreen( + navigationMode: NavigationBarMode = NavigationBarMode.Back, + onBack: () -> Unit +) { + val listState = rememberLazyListState() + AnimatedElevationScaffold( + navigationMode = navigationMode, + listState = listState, + topBarTitle = stringResource(R.string.settings_legal_licences), + onBack = onBack + ) { + val licenses = rememberLicenses() + + val insetPaddings = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + LazyColumn( + modifier = Modifier.padding(), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + contentPadding = PaddingValues( + start = PaddingDefaults.Medium, + top = PaddingDefaults.Medium, + end = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + insetPaddings.calculateBottomPadding() + ) + ) { + licenses.forEach { + item { + LicenseItem(item = it) + } + } + } + } +} + +@Composable +private fun LicenseItem( + modifier: Modifier = Modifier, + item: LicenseEntry +) { + val title = buildAnnotatedString { + append(item.project) + if (!item.version.isNullOrBlank()) { + append(" (${item.version})") + } + if (!item.year.isNullOrBlank()) { + append(" ${item.year}") + } + } + + val uriHandler = LocalUriHandler.current + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + Text(title, style = AppTheme.typography.h6) + Text(item.dependency, style = AppTheme.typography.body2l, fontStyle = FontStyle.Italic) + item.description?.let { + Text(item.description, style = AppTheme.typography.body2l, fontStyle = FontStyle.Italic) + } + item.developers.takeIf { it.isNotEmpty() }?.let { + Text(item.developers.joinToString(), style = AppTheme.typography.body2) + } + item.url?.let { + ClickableTaggedText( + text = annotatedLinkStringLight(item.url, item.url), + style = AppTheme.typography.body2 + ) { + if (it.tag == "URL") { + uriHandler.openUri(it.item) + } + } + } + SpacerSmall() + item.licenses.forEach { + ClickableTaggedText( + text = annotatedLinkStringLight(it.licenseUrl, it.license), + style = AppTheme.typography.body2 + ) { + if (it.tag == "URL") { + uriHandler.openUri(it.item) + } + } + } + } +} + +@Preview +@Composable +private fun LicenseItemPreview() { + AppTheme { + LicenseItem( + item = LicenseEntry( + project = "Test 1234", + description = "Some short description", + version = "1.2.3", + developers = listOf( + "Some Author", + "Another Author", + "And Another One" + ), + url = "https://localhost/123456", + year = "2022", + licenses = listOf( + License( + "Apache License, Version 2.0", + "https://www.apache.org/licenses/LICENSE-2.0" + ), + License( + "Apache License, Version 2.0", + "https://www.apache.org/licenses/LICENSE-2.0" + ) + ), + dependency = "de.abc.def:1.2.3" + ) + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataProtectionDifferences.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataProtectionDifferences.kt index 46a92e48..21e97ca4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataProtectionDifferences.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataProtectionDifferences.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown @@ -56,7 +55,7 @@ fun DPDifferences30112021() { Text( stringResource(R.string.data_terms_first_update_text), modifier = Modifier.fillMaxWidth(), - style = AppTheme.typography.body2l, + style = AppTheme.typography.body2l ) } Spacer32() @@ -95,7 +94,6 @@ fun DPDifferences30112021() { @Composable fun DPSection(title: String, content: @Composable () -> Unit) { - var sectionExpanded by remember { mutableStateOf(false) } val arrow = if (sectionExpanded) { Icons.Rounded.ArrowDropUp @@ -113,14 +111,15 @@ fun DPSection(title: String, content: @Composable () -> Unit) { Text( title, modifier = Modifier.weight(1f), - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) Icon( - imageVector = arrow, contentDescription = "", + imageVector = arrow, + contentDescription = "", modifier = Modifier .size(24.dp) .align(Alignment.CenterVertically), - tint = AppTheme.colors.primary600, + tint = AppTheme.colors.primary600 ) } Spacer8() diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataTermsUpdateScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataTermsUpdateScreen.kt index b002f8ba..076ef528 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataTermsUpdateScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/DataTermsUpdateScreen.kt @@ -31,17 +31,15 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues +import androidx.compose.foundation.layout.statusBarsPadding import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.settings.usecase.DATA_PROTECTION_LAST_UPDATED +import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.BottomAppBar import de.gematik.ti.erp.app.utils.compose.Spacer16 @@ -49,17 +47,18 @@ import de.gematik.ti.erp.app.utils.compose.Spacer24 import de.gematik.ti.erp.app.utils.compose.Spacer48 import de.gematik.ti.erp.app.utils.compose.Spacer8 import de.gematik.ti.erp.app.utils.compose.annotatedStringResource -import java.time.LocalDate +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @Composable fun DataTermsUpdateScreen( - dataProtectionVersionAccepted: LocalDate, + dataProtectionVersionAcceptedOn: Instant, onClickDataTerms: () -> Unit, onAcceptTermsOfUseUpdate: () -> Unit ) { - Scaffold( bottomBar = { BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { @@ -79,12 +78,7 @@ fun DataTermsUpdateScreen( Column( modifier = Modifier.padding(innerPadding) .verticalScroll(rememberScrollState()) - .padding( - rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.statusBars, - applyTop = true - ) - ) + .statusBarsPadding() ) { Column( modifier = Modifier @@ -92,14 +86,14 @@ fun DataTermsUpdateScreen( ) { Text( stringResource(R.string.data_terms_update_header), - style = MaterialTheme.typography.h5, + style = AppTheme.typography.h5, textAlign = TextAlign.Center ) Spacer24() Text( stringResource(R.string.data_terms_update_info), - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) Spacer8() @@ -109,13 +103,15 @@ fun DataTermsUpdateScreen( ) { Text( stringResource(R.string.data_terms_update_open_data_terms), - style = MaterialTheme.typography.caption + style = AppTheme.typography.caption1 ) } val dtFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } - val date = remember(dataProtectionVersionAccepted) { - dataProtectionVersionAccepted.format(dtFormatter) + val date = remember(dataProtectionVersionAcceptedOn) { + OffsetDateTime.ofInstant(dataProtectionVersionAcceptedOn, ZoneId.systemDefault()) + .toLocalDate() + .format(dtFormatter) } val updateInfo = annotatedStringResource( @@ -125,12 +121,12 @@ fun DataTermsUpdateScreen( Spacer48() Text( updateInfo, - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) Spacer16() } Column(modifier = Modifier.fillMaxWidth()) { - if (dataProtectionVersionAccepted < DATA_PROTECTION_LAST_UPDATED) { + if (dataProtectionVersionAcceptedOn < DATA_PROTECTION_LAST_UPDATED) { DPDifferences30112021() } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/FastTrackComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/FastTrackComponents.kt new file mode 100644 index 00000000..53f4c14b --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/FastTrackComponents.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.mainscreen.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.core.LocalIntentHandler +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import io.github.aakira.napier.Napier +import org.kodein.di.LazyDelegate +import org.kodein.di.compose.rememberInstance +import java.net.URI + +@Stable +class FastTrackHandler( + idpUseCase: LazyDelegate +) { + private val idpUseCase by idpUseCase + + /** + * Handles an incoming intent. Returns `true` if the intent could be handled. + */ + @Suppress("TooGenericExceptionCaught") + suspend fun handle(value: String): Boolean = + try { + Napier.d("Authenticate external ...") + idpUseCase.authenticateWithExternalAppAuthorization(URI(value)) + Napier.d("... authenticated") + true + } catch (e: Exception) { + Napier.e(e) { "Couldn't authenticate" } + false + } +} + +@Composable +fun ExternalAuthenticationDialog() { + var showAuthenticationError by remember { mutableStateOf(false) } + + val intentHandler = LocalIntentHandler.current + val authenticator = LocalAuthenticator.current + val idpUseCase = rememberInstance() + val fastTrackHandler = remember { FastTrackHandler(idpUseCase) } + + LaunchedEffect(Unit) { + intentHandler.extAuthIntent.collect { + if (!authenticator.authenticatorExternal.isInProgress && !fastTrackHandler.handle(it)) { + showAuthenticationError = true + } + } + } + + if (showAuthenticationError) { + AcceptDialog( + header = stringResource(R.string.main_fasttrack_error_title), + info = stringResource(R.string.main_fasttrack_error_info), + acceptText = stringResource(R.string.ok) + ) { + showAuthenticationError = false + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/InsecureDeviceScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/InsecureDeviceScreen.kt index 9f014e0f..e0bda6e5 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/InsecureDeviceScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/InsecureDeviceScreen.kt @@ -33,10 +33,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar import androidx.compose.material.Button import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Switch import androidx.compose.material.Text import androidx.compose.material.TextButton @@ -61,8 +59,9 @@ import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.core.MainViewModel import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.BottomAppBar import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall import java.util.Locale @@ -79,14 +78,11 @@ fun InsecureDeviceScreen( pinUseCase: Boolean = true ) { var checked by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberScrollState() - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = headline, - ) { navController.popBackStack() } - }, + AnimatedElevationScaffold( + elevated = scrollState.value > 0, + navigationMode = NavigationBarMode.Close, bottomBar = { BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { Spacer(modifier = Modifier.weight(1f)) @@ -108,13 +104,16 @@ fun InsecureDeviceScreen( } SpacerMedium() } - } + }, + actions = {}, + topBarTitle = headline, + onBack = { navController.popBackStack() } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(PaddingDefaults.Medium) ) { Image( @@ -126,19 +125,19 @@ fun InsecureDeviceScreen( SpacerSmall() Text( headlineBody, - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) SpacerSmall() Text( infoText, - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) if (!pinUseCase) { val uriHandler = LocalUriHandler.current SpacerMedium() Text( stringResource(R.string.insecure_device_safetynet_more_info), - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2, color = AppTheme.colors.neutral600 ) SpacerSmall() @@ -149,8 +148,8 @@ fun InsecureDeviceScreen( ) { Text( stringResource(id = R.string.insecure_device_safetynet_link_text), - style = MaterialTheme.typography.body2, - color = AppTheme.colors.primary600, + style = AppTheme.typography.body2, + color = AppTheme.colors.primary600 ) } } @@ -185,17 +184,17 @@ private fun Toggle( .fillMaxWidth() .padding(PaddingDefaults.Medium) .semantics(true) {}, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { Text( description, - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1, modifier = Modifier.weight(1f) ) SpacerSmall() Switch( checked = checked, - onCheckedChange = null, + onCheckedChange = null ) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt new file mode 100644 index 00000000..da02682b --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenBottomSheetContentState.kt @@ -0,0 +1,593 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.mainscreen.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.QrCode +import androidx.compose.material.icons.rounded.ShoppingBag +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.featuretoggle.FeatureToggleManager +import de.gematik.ti.erp.app.featuretoggle.Features +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.ui.AvatarPicker +import de.gematik.ti.erp.app.profiles.ui.ColorPicker +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.ProfileImage +import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.settings.ui.SettingsScreen +import de.gematik.ti.erp.app.settings.ui.SettingsViewModel +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.BottomSheetAction +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.PrimaryButton +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonLarge +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource +import de.gematik.ti.erp.app.utils.sanitizeProfileName +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel + +@Stable +sealed class MainScreenBottomSheetContentState { + @Stable + object Redeem : MainScreenBottomSheetContentState() + + @Stable + object EditProfile : MainScreenBottomSheetContentState() + + @Stable + class EditOrAddProfileName( + val addProfile: Boolean = false + ) : MainScreenBottomSheetContentState() + + @Stable + object Connect : MainScreenBottomSheetContentState() +} + +@Composable +fun MainScreenBottomSheetContentState( + settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel, + infoContentState: MainScreenBottomSheetContentState?, + redeemState: RedeemState, + mainNavController: NavController, + profileToRename: ProfilesUseCaseData.Profile, + onCancel: () -> Unit +) { + val profileHandler = LocalProfileHandler.current + + val title = when (infoContentState) { + MainScreenBottomSheetContentState.EditProfile -> + stringResource(R.string.mainscreen_bottom_sheet_edit_profile_image) + MainScreenBottomSheetContentState.Connect -> + stringResource(R.string.mainscreen_welcome_drawer_header) + is MainScreenBottomSheetContentState.EditOrAddProfileName -> + stringResource(R.string.bottom_sheet_edit_profile_name_title) + else -> null + } + + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) + .padding(top = PaddingDefaults.Small, bottom = PaddingDefaults.XXLarge), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Rounded.DragHandle, + null, + tint = AppTheme.colors.neutral600 + ) + SpacerMedium() + title?.let { + Text(it, style = AppTheme.typography.subtitle1) + SpacerMedium() + } + Box( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + infoContentState?.let { + when (it) { + MainScreenBottomSheetContentState.Redeem -> + RedeemSheetContent( + redeemState = redeemState, + onClickLocalRedeem = { taskIds -> + mainNavController.navigate( + MainNavigationScreens.RedeemLocally.path( + TaskIds(taskIds) + ) + ) + }, + onClickOnlineRedeem = { + mainNavController.navigate( + MainNavigationScreens.Pharmacies.path() + ) + } + ) + MainScreenBottomSheetContentState.EditProfile -> + EditProfileAvatar( + profile = profileHandler.activeProfile, + clearPersonalizedImage = { + profileSettingsViewModel.clearPersonalizedImage(profileHandler.activeProfile.id) + }, + onPickPersonalizedImage = { + mainNavController.navigate( + MainNavigationScreens.ProfileImageCropper.path( + profileId = profileHandler.activeProfile.id + ) + ) + }, + onSelectAvatar = { avatar -> + profileSettingsViewModel.saveAvatarFigure(profileHandler.activeProfile.id, avatar) + }, + onSelectProfileColor = { color -> + profileSettingsViewModel.updateProfileColor(profileHandler.activeProfile, color) + } + ) + is MainScreenBottomSheetContentState.EditOrAddProfileName -> + ProfileSheetContent( + settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel, + addProfile = it.addProfile, + profileToEdit = if (!it.addProfile) { + profileToRename + } else { null }, + onCancel = onCancel + ) + MainScreenBottomSheetContentState.Connect -> + ConnectBottomSheetContent(onClickConnect = { + mainNavController.navigate( + MainNavigationScreens.CardWall.path(profileHandler.activeProfile.id) + ) + }, onCancel) + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ProfileSheetContent( + settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel, + profileToEdit: ProfilesUseCaseData.Profile?, + addProfile: Boolean = false, + onCancel: () -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + + val settingsScreenState by produceState(SettingsScreen.defaultState) { + settingsViewModel.screenState().collect { + value = it + } + } + var textValue by remember { mutableStateOf(profileToEdit?.name ?: "") } + var duplicated by remember { mutableStateOf(false) } + + val onEdit = { + if (!addProfile) { + profileToEdit?.let { profileSettingsViewModel.updateProfileName(it.id, textValue) } + } else { + settingsViewModel.addProfile(textValue) + } + onCancel() + keyboardController?.hide() + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + modifier = Modifier.testTag(TestTag.Settings.AddProfileDialog.ProfileNameTextField), + shape = RoundedCornerShape(8.dp), + value = textValue, + singleLine = true, + onValueChange = { + val name = sanitizeProfileName(it.trimStart()) + textValue = name + duplicated = textValue.trim() != profileToEdit?.name && + settingsScreenState.containsProfileWithName(textValue) + }, + keyboardOptions = KeyboardOptions( + autoCorrect = true, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + if (!duplicated && textValue.isNotEmpty()) { + onEdit() + } + }, + placeholder = { Text(stringResource(R.string.profile_edit_name_place_holder)) }, + isError = duplicated + ) + + if (duplicated) { + Text( + stringResource(R.string.edit_profile_duplicated_profile_name), + color = AppTheme.colors.red600, + style = AppTheme.typography.caption1, + modifier = Modifier.padding(start = PaddingDefaults.Medium) + ) + } + SpacerLarge() + PrimaryButton( + modifier = Modifier.testTag(TestTag.Settings.AddProfileDialog.ConfirmButton), + enabled = !duplicated && textValue.isNotEmpty(), + onClick = { + onEdit() + } + ) { + Text(stringResource(R.string.profile_bottom_sheet_save)) + } + } +} + +@Composable +private fun EditProfileAvatar( + profile: ProfilesUseCaseData.Profile, + clearPersonalizedImage: () -> Unit, + onPickPersonalizedImage: () -> Unit, + onSelectAvatar: (ProfilesData.AvatarFigure) -> Unit, + onSelectProfileColor: (ProfilesData.ProfileColorNames) -> Unit +) { + ProfileColorAndImagePickerContent( + profile, + clearPersonalizedImage = clearPersonalizedImage, + onPickPersonalizedImage = onPickPersonalizedImage, + onSelectAvatar = onSelectAvatar, + onSelectProfileColor = onSelectProfileColor + ) +} + +@Composable +private fun ProfileColorAndImagePickerContent( + profile: ProfilesUseCaseData.Profile, + clearPersonalizedImage: () -> Unit, + onPickPersonalizedImage: () -> Unit, + onSelectAvatar: (ProfilesData.AvatarFigure) -> Unit, + onSelectProfileColor: (ProfilesData.ProfileColorNames) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + SpacerMedium() + ProfileImage(profile) { + clearPersonalizedImage() + } + + SpacerXXLarge() + AvatarPicker( + profile = profile, + currentAvatarFigure = profile.avatarFigure, + onPickPersonalizedImage = onPickPersonalizedImage, + onSelectAvatar = onSelectAvatar + ) + + if (profile.avatarFigure != ProfilesData.AvatarFigure.PersonalizedImage) { + SpacerXXLarge() + SpacerMedium() + Text( + stringResource(R.string.edit_profile_background_color), + style = AppTheme.typography.h6 + ) + SpacerLarge() + + ColorPicker(profile.color, onSelectProfileColor) + SpacerLarge() + } + } +} + +@Composable +private fun ConnectBottomSheetContent(onClickConnect: () -> Unit, onCancel: () -> Unit) { + Column( + Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.mainscreen_welcome_drawer_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + SpacerLarge() + PrimaryButtonLarge( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTag.MainScreenBottomSheet.ConnectButton), + onClick = onClickConnect, + colors = ButtonDefaults.buttonColors( + backgroundColor = AppTheme.colors.primary100, + contentColor = AppTheme.colors.primary700 + ) + ) { + Text( + stringResource(R.string.mainscreen_connect_bottomsheet_connect) + ) + } + SpacerMedium() + TextButton( + onClick = onCancel, + modifier = Modifier + .fillMaxSize() + .testTag(TestTag.MainScreenBottomSheet.ConnectLaterButton), + contentPadding = PaddingValues( + vertical = 13.dp + ) + ) { + Text( + stringResource(R.string.mainscreen_connect_bottomsheet_connect_later) + ) + } + } +} + +interface RedeemStateBridge { + fun scannedTasks(profileIdentifier: ProfileIdentifier): Flow> + fun syncedTasks(profileIdentifier: ProfileIdentifier): Flow> + fun allowRedeemWithoutTiFeatureEnabled(): Flow +} + +class RedeemStateViewModel( + private val prescriptionUseCase: PrescriptionUseCase, + private val toggleManager: FeatureToggleManager +) : ViewModel(), RedeemStateBridge { + + override fun scannedTasks(profileIdentifier: ProfileIdentifier) = + prescriptionUseCase.scannedTasks(profileIdentifier) + .shareIn(viewModelScope, SharingStarted.Eagerly) + + override fun syncedTasks(profileIdentifier: ProfileIdentifier) = + prescriptionUseCase.syncedTasks(profileIdentifier) + .shareIn(viewModelScope, SharingStarted.Eagerly) + + override fun allowRedeemWithoutTiFeatureEnabled() = + toggleManager.isFeatureEnabled(Features.REDEEM_WITHOUT_TI.featureName) +} + +@Stable +class RedeemState( + private val redeemStateBridge: RedeemStateBridge +) { + @Stable + private class InternalState( + val onPremiseRedeemableTaskIds: List, + val onlineRedeemableTaskIds: List, + val redeemedMedicationNames: List + ) + + private val timeTrigger = MutableSharedFlow() + + private var internalState by mutableStateOf(InternalState(emptyList(), emptyList(), emptyList())) + + val localTaskIds by derivedStateOf { internalState.onPremiseRedeemableTaskIds } + + val onlineTaskIds by derivedStateOf { internalState.onlineRedeemableTaskIds } + + val alreadyRedeemedMedications by derivedStateOf { internalState.redeemedMedicationNames } + + val hasRedeemableTasks by derivedStateOf { onlineTaskIds.isNotEmpty() || localTaskIds.isNotEmpty() } + + suspend fun produceState(profileIdentifier: ProfileIdentifier) = coroutineScope { + launch { + while (true) { + delay(timeMillis = 60_000L) + timeTrigger.emit(Unit) + } + } + combine( + redeemStateBridge.allowRedeemWithoutTiFeatureEnabled(), + redeemStateBridge.scannedTasks(profileIdentifier), + redeemStateBridge.syncedTasks(profileIdentifier), + timeTrigger.onStart { emit(Unit) } + ) { allowWithout, scannedTasks, syncedTasks, _ -> + val redeemableSyncedTasks = syncedTasks + .asSequence() + .filter { + it.redeemState().isRedeemable() + } + + val alreadyRedeemedSyncedTasks = syncedTasks + .asSequence() + .filter { + it.redeemState() == SyncedTaskData.SyncedTask.RedeemState.RedeemableAfterDelta + } + .map { + it.medicationRequestMedicationName() ?: "" + } + .take(2) // we only require at least two + + val allRedeemableTasks = + scannedTasks.filter { it.isRedeemable() }.map { it.taskId } + redeemableSyncedTasks.map { it.taskId } + + InternalState( + onPremiseRedeemableTaskIds = allRedeemableTasks, + onlineRedeemableTaskIds = if (allowWithout) { + allRedeemableTasks + } else { + redeemableSyncedTasks.map { it.taskId }.toList() + }, + redeemedMedicationNames = alreadyRedeemedSyncedTasks.toList() + ) + }.collect { + internalState = it + } + } +} + +@Composable +fun rememberRedeemState(profile: ProfilesUseCaseData.Profile): RedeemState { + val redeemStateViewModel by rememberViewModel() + val state = remember { RedeemState(redeemStateViewModel) } + LaunchedEffect(profile.id) { + state.produceState(profile.id) + } + return state +} + +@Composable +private fun RedeemSheetContent( + redeemState: RedeemState, + onClickLocalRedeem: (taskIds: List) -> Unit, + onClickOnlineRedeem: (taskIds: List) -> Unit +) { + val onlineRedeemButtonEnabled by derivedStateOf { + redeemState.onlineTaskIds.isNotEmpty() + } + + val shouldShowAlreadySentDialog by derivedStateOf { + redeemState.alreadyRedeemedMedications.isNotEmpty() + } + + var showAlreadySentDialog by remember { mutableStateOf(false) } + + if (showAlreadySentDialog) { + SendTasksAgainDialog( + redeemedMedicationNames = redeemState.alreadyRedeemedMedications, + onSendAgain = { + onClickOnlineRedeem(redeemState.onlineTaskIds) + showAlreadySentDialog = false + }, + onCancel = { + showAlreadySentDialog = false + } + ) + } + + Column { + BottomSheetAction( + icon = Icons.Rounded.QrCode, + title = stringResource(R.string.dialog_redeem_headline), + info = stringResource(R.string.dialog_redeem_info), + modifier = Modifier.testTag("main/redeemInLocalPharmacyButton") + ) { + onClickLocalRedeem(redeemState.localTaskIds) + } + + BottomSheetAction( + enabled = onlineRedeemButtonEnabled, + icon = Icons.Rounded.ShoppingBag, + title = stringResource(R.string.dialog_order_headline), + info = stringResource(R.string.dialog_order_info), + modifier = Modifier.testTag("main/redeemRemoteButton") + ) { + if (shouldShowAlreadySentDialog) { + showAlreadySentDialog = true + } else { + onClickOnlineRedeem(redeemState.onlineTaskIds) + } + } + + Box(Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun SendTasksAgainDialog( + redeemedMedicationNames: List, + onSendAgain: () -> Unit, + onCancel: () -> Unit +) { + val medication = remember(redeemedMedicationNames) { redeemedMedicationNames.first() } + + val taskAlreadySentInfo = buildAnnotatedString { + append( + annotatedPluralsResource( + R.plurals.task_already_sent_info, + redeemedMedicationNames.size, + AnnotatedString(medication) + ) + ) + append("\n\n") + append(stringResource(R.string.task_already_sent_sub_info)) + } + + CommonAlertDialog( + header = AnnotatedString(stringResource(R.string.task_already_sent_header)), + info = taskAlreadySentInfo, + cancelText = stringResource(R.string.cancel_sent_task_again), + actionText = stringResource(R.string.sent_task_again), + onCancel = onCancel, + onClickAction = onSendAgain + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt index 3cf9e4a5..c1f8136c 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt @@ -18,36 +18,32 @@ package de.gematik.ti.erp.app.mainscreen.ui +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.expandVertically +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AppBarDefaults import androidx.compose.material.BottomNavigationItem -import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ExtendedFloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -56,43 +52,40 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.MarkChatRead import androidx.compose.material.icons.outlined.MarkChatUnread import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.rounded.ArrowDropDown -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.QrCode -import androidx.compose.material.icons.rounded.ShoppingBag -import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material.icons.rounded.AddCircle +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.PersonAdd import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -100,72 +93,88 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions -import com.google.accompanist.insets.navigationBarsHeight -import com.google.accompanist.insets.systemBarsPadding import com.google.mlkit.common.sdkinternal.MlKitContext +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.LegalNoticeWithScaffold import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.analytics.TrackNavigationChanges +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.cardunlock.ui.UnlockEgKScreen import de.gematik.ti.erp.app.cardwall.ui.CardWallScreen -import de.gematik.ti.erp.app.core.LocalActivity import de.gematik.ti.erp.app.core.MainViewModel -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken -import de.gematik.ti.erp.app.mainscreen.ui.model.MainScreenData -import de.gematik.ti.erp.app.messages.ui.DisplayPickupScreen -import de.gematik.ti.erp.app.messages.ui.MessageScreen -import de.gematik.ti.erp.app.messages.ui.MessageViewModel +import de.gematik.ti.erp.app.debug.ui.DebugScreenWrapper +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.license.ui.LicenseScreen import de.gematik.ti.erp.app.onboarding.ui.OnboardingNavigationScreens -import de.gematik.ti.erp.app.onboarding.ui.OnboardingProfile import de.gematik.ti.erp.app.onboarding.ui.OnboardingScreen +import de.gematik.ti.erp.app.onboarding.ui.OnboardingSecureAppMethod import de.gematik.ti.erp.app.onboarding.ui.ReturningUserSecureAppOnboardingScreen -import de.gematik.ti.erp.app.pharmacy.ui.PharmacySearchScreenWithNavigation +import de.gematik.ti.erp.app.orderhealthcard.ui.HealthCardContactOrderScreen +import de.gematik.ti.erp.app.orders.ui.MessageScreen +import de.gematik.ti.erp.app.orders.ui.OrderScreen +import de.gematik.ti.erp.app.pharmacy.ui.PharmacyNavigation import de.gematik.ti.erp.app.prescription.detail.ui.PrescriptionDetailsScreen +import de.gematik.ti.erp.app.prescription.ui.ArchiveScreen import de.gematik.ti.erp.app.prescription.ui.PrescriptionScreen +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionViewModel +import de.gematik.ti.erp.app.prescription.ui.ScanPrescriptionViewModel import de.gematik.ti.erp.app.prescription.ui.ScanScreen -import de.gematik.ti.erp.app.profiles.ui.Avatar +import de.gematik.ti.erp.app.prescription.ui.rememberRefreshPrescriptionsController +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.ui.DefaultProfile import de.gematik.ti.erp.app.profiles.ui.EditProfileScreen -import de.gematik.ti.erp.app.profiles.ui.connectionText -import de.gematik.ti.erp.app.profiles.ui.connectionTextColor -import de.gematik.ti.erp.app.profiles.ui.profileColor +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.ProfileImageCropper +import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.redeem.ui.RedeemScreen +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.settings.ui.AllowAnalyticsScreen +import de.gematik.ti.erp.app.settings.ui.AllowBiometryScreen +import de.gematik.ti.erp.app.settings.ui.PharmacyLicenseScreen +import de.gematik.ti.erp.app.settings.ui.SecureAppWithPassword import de.gematik.ti.erp.app.settings.ui.SettingsScreen -import de.gematik.ti.erp.app.settings.ui.SettingsScrollTo import de.gematik.ti.erp.app.settings.ui.SettingsViewModel import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.tracking.TrackNavigationChanges import de.gematik.ti.erp.app.utils.compose.BottomNavigation -import de.gematik.ti.erp.app.utils.compose.BottomSheetAction import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog import de.gematik.ti.erp.app.utils.compose.NavigationAnimation -import de.gematik.ti.erp.app.utils.compose.Dialog -import de.gematik.ti.erp.app.utils.compose.navigationModeState +import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.SpacerTiny -import de.gematik.ti.erp.app.utils.compose.TopAppBar -import de.gematik.ti.erp.app.utils.compose.createToastShort -import de.gematik.ti.erp.app.utils.compose.minimalSystemBarsPadding -import de.gematik.ti.erp.app.utils.compose.testId +import de.gematik.ti.erp.app.utils.compose.TopAppBarWithContent +import de.gematik.ti.erp.app.utils.compose.navigationModeState import de.gematik.ti.erp.app.webview.URI_DATA_TERMS +import de.gematik.ti.erp.app.webview.URI_TERMS_OF_USE import de.gematik.ti.erp.app.webview.WebViewScreen -import de.gematik.ti.erp.app.utils.dateTimeShortText -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import java.time.LocalDate +import kotlinx.coroutines.withContext +import org.kodein.di.compose.rememberViewModel +import java.time.Instant -@OptIn(ExperimentalMaterialApi::class) +const val Third = 1 / 3f +const val StateVisibilityTime = 4000L +const val TweenDuration = 2000 +const val VisibleProfileNameLength = 10 + +@Suppress("LongMethod") @Composable fun MainScreen( navController: NavHostController, mainViewModel: MainViewModel, - settingsViewModel: SettingsViewModel = hiltViewModel() + mainScreenViewModel: MainScreenViewModel, + settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel ) { - LaunchedEffect(Unit) { mainViewModel.authenticationMethod.collect { - if (!mainViewModel.isNewUser && !(it == SettingsAuthenticationMethod.Password || it == SettingsAuthenticationMethod.DeviceSecurity)) { + if (!mainViewModel.showOnboarding && !(it is SettingsData.AuthenticationMode.Password || it == SettingsData.AuthenticationMode.DeviceSecurity)) { navController.navigate(MainNavigationScreens.ReturningUserSecureAppOnboarding.path()) { launchSingleTop = true popUpTo(MainNavigationScreens.Prescriptions.path()) { @@ -178,9 +187,10 @@ fun MainScreen( val startDestination = when { - mainViewModel.isNewUser -> { + mainViewModel.showOnboarding -> { MainNavigationScreens.Onboarding.route } + else -> { MainNavigationScreens.Prescriptions.route } @@ -188,27 +198,47 @@ fun MainScreen( TrackNavigationChanges(navController) val navigationMode by navController.navigationModeState(OnboardingNavigationScreens.Onboarding.route) - + var secureMethod by rememberSaveable { mutableStateOf(OnboardingSecureAppMethod.None) } NavHost( navController, startDestination = startDestination ) { composable(MainNavigationScreens.Onboarding.route) { - OnboardingScreen(navController) + OnboardingScreen( + mainNavController = navController, + settingsViewModel = settingsViewModel + ) } composable(MainNavigationScreens.ReturningUserSecureAppOnboarding.route) { - ReturningUserSecureAppOnboardingScreen(navController) + ReturningUserSecureAppOnboardingScreen( + navController, + secureMethod = secureMethod, + onSecureMethodChange = { secureMethod = it }, + settingsViewModel = settingsViewModel + ) + } + composable(OnboardingNavigationScreens.Biometry.route) { + NavigationAnimation(mode = navigationMode) { + AllowBiometryScreen( + onBack = { navController.popBackStack() }, + onNext = { navController.popBackStack() }, + onSecureMethodChange = { secureMethod = it } + ) + } } composable(MainNavigationScreens.DataTermsUpdateScreen.route) { - val dataProtectionVersionAccepted by mainViewModel.dataProtectionVersionAccepted().collectAsState( - initial = LocalDate.MIN - ) - DataTermsUpdateScreen( - dataProtectionVersionAccepted, - onClickDataTerms = { navController.navigate(MainNavigationScreens.DataProtection.route) } - ) { - mainViewModel.acceptUpdatedDataTerms(LocalDate.now()) - navController.navigate(MainNavigationScreens.Prescriptions.route) + val dataProtectionVersionAccepted: Instant? by mainViewModel + .dataProtectionVersionAcceptedOn() + .collectAsState(initial = null) + + dataProtectionVersionAccepted?.let { acceptedOn -> + DataTermsUpdateScreen( + acceptedOn, + onClickDataTerms = { navController.navigate(MainNavigationScreens.DataProtection.route) } + ) { + mainViewModel.acceptUpdatedDataTerms() + navController.navigate(MainNavigationScreens.Prescriptions.route) + } } } composable(MainNavigationScreens.DataProtection.route) { @@ -224,42 +254,40 @@ fun MainScreen( MainNavigationScreens.Settings.route, MainNavigationScreens.Settings.arguments ) { - val scrollTo = remember { it.arguments?.get("scrollToSection") as SettingsScrollTo } - SettingsScreen(scrollTo = scrollTo, navController) + SettingsScreen( + mainNavController = navController, + settingsViewModel = settingsViewModel + ) } composable(MainNavigationScreens.Camera.route) { - ScanScreen(navController) + val scanViewModel by rememberViewModel() + ScanScreen(mainNavController = navController, scanViewModel = scanViewModel) } composable(MainNavigationScreens.Prescriptions.route) { - MainScreenWithScaffold(navController, mainViewModel) + MainScreenWithScaffold( + mainNavController = navController, + mainViewModel = mainViewModel, + mainScreenViewModel = mainScreenViewModel, + settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel + ) } - composable(MainNavigationScreens.ProfileSetup.route) { - var profileName by remember { mutableStateOf("") } - OnboardingProfile( - modifier = Modifier.minimalSystemBarsPadding(), - isReturningUser = true, - profileName = profileName, - onProfileNameChange = { profileName = it } - ) { - mainViewModel.overwriteDefaultProfile(profileName) - navController.popBackStack() - } - } composable( MainNavigationScreens.PrescriptionDetail.route, - MainNavigationScreens.PrescriptionDetail.arguments, + MainNavigationScreens.PrescriptionDetail.arguments ) { val taskId = remember { requireNotNull(it.arguments?.getString("taskId")) } - PrescriptionDetailsScreen(taskId, navController) + PrescriptionDetailsScreen(taskId = taskId, mainNavController = navController) } composable( MainNavigationScreens.Pharmacies.route, - MainNavigationScreens.Pharmacies.arguments, + MainNavigationScreens.Pharmacies.arguments ) { - val taskIds = - remember { requireNotNull(it.arguments?.getParcelable("taskIds") as? TaskIds) } - PharmacySearchScreenWithNavigation(taskIds, navController) + PharmacyNavigation( + mainNavController = navController, + mainScreenVM = mainScreenViewModel + ) } composable(MainNavigationScreens.InsecureDeviceScreen.route) { InsecureDeviceScreen( @@ -291,164 +319,382 @@ fun MainScreen( val taskIds = remember { requireNotNull(it.arguments?.getParcelable("taskIds") as? TaskIds) } RedeemScreen( - taskIds, - navController + taskIds = taskIds, + navController = navController ) } composable( - MainNavigationScreens.PickUpCode.route, - MainNavigationScreens.PickUpCode.arguments + MainNavigationScreens.Messages.route, + MainNavigationScreens.Messages.arguments ) { - val pickUpCodeHR = - remember { navController.currentBackStackEntry?.arguments?.getString("pickUpCodeHR") } - val pickUpCodeDMC = - remember { navController.currentBackStackEntry?.arguments?.getString("pickUpCodeDMC") } - DisplayPickupScreen( - navController, - pickupCodeHR = pickUpCodeHR, - pickupCodeDMC = pickUpCodeDMC + val orderId = + remember { it.arguments?.getString("orderId")!! } + + MessageScreen( + orderId = orderId, + mainNavController = navController ) } composable( MainNavigationScreens.CardWall.route, - MainNavigationScreens.CardWall.arguments, + MainNavigationScreens.CardWall.arguments ) { - val canAvailable = remember { - navController.currentBackStackEntry?.arguments?.getBoolean("can") ?: false - } - CardWallScreen(onFinishedCardWall = { - navController.navigate( - MainNavigationScreens.Prescriptions.path(), - navOptions { - popUpTo(MainNavigationScreens.Prescriptions.route) { - inclusive = true + val profileId = + remember { it.arguments?.getString("profileId")!! } + CardWallScreen( + navController, + onResumeCardWall = { + navController.navigate( + MainNavigationScreens.Prescriptions.path(), + navOptions { + popUpTo(MainNavigationScreens.Prescriptions.route) { + inclusive = true + } } - } - ) - }, canAvailable) + ) + }, + profileId = profileId + ) } composable( MainNavigationScreens.EditProfile.route, - MainNavigationScreens.EditProfile.arguments, + MainNavigationScreens.EditProfile.arguments ) { val profileId = - remember { navController.currentBackStackEntry?.arguments?.getInt("profileId")!! } + remember { navController.currentBackStackEntry?.arguments?.getString("profileId")!! } EditProfileScreen( profileId, settingsViewModel, + profileSettingsViewModel, onBack = { navController.popBackStack() }, mainNavController = navController ) } + composable(MainNavigationScreens.Debug.route) { + DebugScreenWrapper(navController) + } + composable(MainNavigationScreens.Terms.route) { + NavigationAnimation(mode = navigationMode) { + WebViewScreen( + title = stringResource(R.string.onb_terms_of_use), + onBack = { navController.popBackStack() }, + url = URI_TERMS_OF_USE + ) + } + } + composable(MainNavigationScreens.Imprint.route) { + NavigationAnimation(mode = navigationMode) { + LegalNoticeWithScaffold( + navController + ) + } + } + composable(MainNavigationScreens.DataProtection.route) { + NavigationAnimation(mode = navigationMode) { + WebViewScreen( + title = stringResource(R.string.onb_data_consent), + onBack = { navController.popBackStack() }, + url = URI_DATA_TERMS + ) + } + } + composable(MainNavigationScreens.OpenSourceLicences.route) { + NavigationAnimation(mode = navigationMode) { + LicenseScreen( + onBack = { navController.popBackStack() } + ) + } + } + composable(MainNavigationScreens.AdditionalLicences.route) { + NavigationAnimation(mode = navigationMode) { + PharmacyLicenseScreen { + navController.popBackStack() + } + } + } + composable(MainNavigationScreens.AllowAnalytics.route) { + NavigationAnimation(mode = navigationMode) { + AllowAnalyticsScreen( + onBack = { navController.popBackStack() }, + onAllowAnalytics = { + if (it) { + settingsViewModel.onTrackingAllowed() + } else { + settingsViewModel.onTrackingDisallowed() + } + } + ) + } + } + composable(MainNavigationScreens.Password.route) { + NavigationAnimation(mode = navigationMode) { + SecureAppWithPassword( + navController, + settingsViewModel + ) + } + } + composable(MainNavigationScreens.OrderHealthCard.route) { + HealthCardContactOrderScreen(onBack = { navController.popBackStack() }) + } + composable( + MainNavigationScreens.EditProfile.route, + MainNavigationScreens.EditProfile.arguments + ) { + val profileId = remember { it.arguments!!.getString("profileId")!! } + + val state by produceState(SettingsScreen.defaultState) { + settingsViewModel.screenState().collect { + value = it + } + } + + state.profileById(profileId)?.let { profile -> + EditProfileScreen( + state, + profile, + settingsViewModel, + profileSettingsViewModel, + onRemoveProfile = { + settingsViewModel.removeProfile(profile, it) + navController.popBackStack() + }, + onBack = { navController.popBackStack() }, + mainNavController = navController + ) + } + } + + composable( + MainNavigationScreens.ProfileImageCropper.route, + MainNavigationScreens.ProfileImageCropper.arguments + ) { + val profileId = remember { it.arguments!!.getString("profileId")!! } + + ProfileImageCropper( + onSaveCroppedImage = { + profileSettingsViewModel.savePersonalizedProfileImage(profileId, it) + navController.popBackStack() + }, + onBack = { + navController.popBackStack() + } + ) + } + + composable( + MainNavigationScreens.UnlockEgk.route, + MainNavigationScreens.UnlockEgk.arguments + ) { + val unlockMethod = remember { it.arguments!!.getString("unlockMethod") } + + NavigationAnimation(mode = navigationMode) { + UnlockEgKScreen( + unlockMethod = when (unlockMethod) { + UnlockMethod.ChangeReferenceData.name -> UnlockMethod.ChangeReferenceData + UnlockMethod.ResetRetryCounter.name -> UnlockMethod.ResetRetryCounter + UnlockMethod.ResetRetryCounterWithNewSecret.name -> UnlockMethod.ResetRetryCounterWithNewSecret + else -> UnlockMethod.None + }, + navController = navController, + onClickLearnMore = { + navController.navigate( + MainNavigationScreens.OrderHealthCard.path() + ) + } + ) + } + } + + composable( + MainNavigationScreens.Archive.route + ) { + val prescriptionViewModel by rememberViewModel() + + NavigationAnimation(mode = navigationMode) { + ArchiveScreen(prescriptionViewModel = prescriptionViewModel, navController = navController) { + navController.popBackStack() + } + } + } } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) +@Suppress("LongMethod") +@OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreenWithScaffold( mainNavController: NavController, - mainViewModel: MainViewModel = hiltViewModel(LocalActivity.current), - mainScreenVM: MainScreenViewModel = hiltViewModel(LocalActivity.current), - messageVM: MessageViewModel = hiltViewModel() + mainViewModel: MainViewModel, + mainScreenViewModel: MainScreenViewModel, + settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel ) { + val profileHandler = LocalProfileHandler.current + val redeemState = rememberRedeemState(profileHandler.activeProfile) LaunchedEffect(Unit) { - if (mainViewModel.showDataTermsUpdate.first()) { - mainNavController.navigate(MainNavigationScreens.DataTermsUpdateScreen.path()) - } else if (mainViewModel.showInsecureDevicePrompt.first()) { - mainNavController.navigate(MainNavigationScreens.InsecureDeviceScreen.path()) - } else if (mainViewModel.showProfileSetupPrompt.first()) { - mainNavController.navigate(MainNavigationScreens.ProfileSetup.path()) + withContext(Dispatchers.Main) { + if (mainViewModel.showDataTermsUpdate.first()) { + mainNavController.navigate( + MainNavigationScreens.DataTermsUpdateScreen.path(), + navOptions { + launchSingleTop = true + popUpTo(MainNavigationScreens.Prescriptions.path()) { + inclusive = true + } + } + ) + } else if (mainViewModel.showInsecureDevicePrompt.first()) { + mainNavController.navigate(MainNavigationScreens.InsecureDeviceScreen.path()) + } } } LaunchedEffect(Unit) { + if (BuildKonfig.INTERNAL) { + return@LaunchedEffect + } + mainViewModel.showSafetynetPrompt.collect { if (!it) { - mainNavController.navigate(MainNavigationScreens.SafetynetNotOkScreen.route) + withContext(Dispatchers.Main) { + mainNavController.navigate(MainNavigationScreens.SafetynetNotOkScreen.route) + } } } } - val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val scaffoldState = rememberScaffoldState() + + MainScreenSnackbar( + mainScreenViewModel = mainScreenViewModel, + scaffoldState = scaffoldState + ) + + OrderSuccessDialog(mainScreenViewModel) + + val coroutineScope = rememberCoroutineScope() - val redeemState by produceState(MainScreenData.emptyRedeemState) { - mainScreenVM.redeemState().collect { - value = it + var mainScreenBottomSheetContentState: MainScreenBottomSheetContentState? by remember { mutableStateOf(null) } + + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden, + confirmStateChange = { + it != ModalBottomSheetValue.HalfExpanded + } + ) + LaunchedEffect(Unit) { + sheetState.snapTo(ModalBottomSheetValue.Hidden) + } + + LaunchedEffect(mainScreenBottomSheetContentState) { + if (mainScreenBottomSheetContentState != null) { + sheetState.show() + } else { + sheetState.hide() } } LaunchedEffect(Unit) { - sheetState.snapTo(ModalBottomSheetValue.Hidden) + if (mainViewModel.showWelcomeDrawer.first()) { + mainScreenBottomSheetContentState = MainScreenBottomSheetContentState.Connect + mainViewModel.welcomeDrawerShown() + } } - val scaffoldState = rememberScaffoldState() + LaunchedEffect(sheetState.isVisible) { + if (sheetState.targetValue == ModalBottomSheetValue.Hidden) { + mainScreenBottomSheetContentState = null + } + } - MainScreenSnackbar( - mainScreenViewModel = mainScreenVM, - scaffoldState = scaffoldState, - ) + var profileToRename by remember { + mutableStateOf(DefaultProfile) + } - val coroutineScope = rememberCoroutineScope() + BackHandler(enabled = sheetState.isVisible) { + coroutineScope.launch { + sheetState.hide() + } + } ModalBottomSheetLayout( sheetState = sheetState, + modifier = Modifier.imePadding(), + sheetShape = remember { RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) }, sheetContent = { - BottomSheetAction( - icon = Icons.Rounded.QrCode, - title = stringResource(R.string.dialog_redeem_headline), - info = stringResource(R.string.dialog_redeem_info), - modifier = Modifier.testTag("main/redeemInLocalPharmacyButton") - ) { - mainNavController.navigate( - MainNavigationScreens.RedeemLocally.path( - TaskIds(redeemState.scannedTaskIds + redeemState.syncedTaskIds) - ) - ) - } - - BottomSheetAction( - enabled = redeemState.syncedTaskIds.isNotEmpty(), - icon = Icons.Rounded.ShoppingBag, - title = stringResource(R.string.dialog_order_headline), - info = stringResource(R.string.dialog_order_info), - modifier = Modifier.testTag("main/redeemRemoteButton") - ) { - mainNavController.navigate( - MainNavigationScreens.Pharmacies.path( - TaskIds(redeemState.syncedTaskIds) - ) - ) - } - - Box(Modifier.navigationBarsHeight()) + MainScreenBottomSheetContentState( + settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel, + infoContentState = mainScreenBottomSheetContentState, + redeemState = redeemState, + mainNavController = mainNavController, + profileToRename = profileToRename, + onCancel = { + coroutineScope.launch { + sheetState.hide() + } + } + ) } ) { val bottomNavController = rememberNavController() - val showFab by produceState(false) { - bottomNavController.currentBackStackEntryFlow.collect { - value = it.destination.route == MainNavigationScreens.Prescriptions.route - } - } + val currentBottomNavigationRoute by bottomNavController + .currentBackStackEntryFlow + .collectAsState(null) + + // TODO: move to general place? + ExternalAuthenticationDialog() + + var topBarElevated by remember { mutableStateOf(true) } Scaffold( - topBar = { MultiProfileTopAppBar(mainNavController, mainScreenVM) }, - bottomBar = { MainScreenBottomNavigation(mainNavController, bottomNavController) }, - floatingActionButton = { - AnimatedVisibility( - visible = redeemState.hasRedeemableTasks() && showFab, - enter = fadeIn(), - exit = fadeOut(), - ) { - ExtendedFloatingActionButton( - modifier = Modifier.heightIn(min = 56.dp), - text = { Text(stringResource(R.string.main_redeem_button)) }, - icon = { Icon(Icons.Rounded.Upload, null) }, - onClick = { coroutineScope.launch { sheetState.show() } } + modifier = Modifier.testTag(TestTag.Main.MainScreen), + topBar = { + val isInPrescriptionScreen by derivedStateOf { + currentBottomNavigationRoute?.destination?.route == MainNavigationScreens.Prescriptions.route + } + + if (currentBottomNavigationRoute?.destination?.route != MainNavigationScreens.Settings.route) { + MultiProfileTopAppBar( + navController = mainNavController, + elevated = topBarElevated, + mainScreenViewModel = mainScreenViewModel, + isInPrescriptionScreen = isInPrescriptionScreen, + onClickAddProfile = { + mainScreenBottomSheetContentState = + MainScreenBottomSheetContentState.EditOrAddProfileName(addProfile = true) + }, + onClickChangeProfileName = { profile -> + profileToRename = profile + mainScreenBottomSheetContentState = MainScreenBottomSheetContentState.EditOrAddProfileName() + } ) } }, + bottomBar = { + MainScreenBottomNavigation( + navController = mainNavController, + viewModel = mainScreenViewModel, + bottomNavController = bottomNavController, + profileId = profileHandler.activeProfile.id + ) + }, + floatingActionButton = { + val showRedeemFab by derivedStateOf { + redeemState.hasRedeemableTasks && + currentBottomNavigationRoute?.destination?.route == + MainNavigationScreens.Prescriptions.route + } + RedeemFloatingActionButton( + visible = showRedeemFab, + onClick = { + mainScreenBottomSheetContentState = MainScreenBottomSheetContentState.Redeem + } + ) + }, scaffoldState = scaffoldState ) { innerPadding -> Box( @@ -461,10 +707,37 @@ fun MainScreenWithScaffold( startDestination = MainNavigationScreens.Prescriptions.path() ) { composable(MainNavigationScreens.Prescriptions.route) { - PrescriptionScreen(mainNavController, uri = mainViewModel.externalAuthorizationUri) + val prescriptionViewModel by rememberViewModel() + PrescriptionScreen( + navController = mainNavController, + onClickAvatar = { + mainScreenBottomSheetContentState = MainScreenBottomSheetContentState.EditProfile + }, + prescriptionViewModel = prescriptionViewModel, + mainScreenViewModel = mainScreenViewModel, + onElevateTopBar = { + topBarElevated = it + }, + onClickArchive = { mainNavController.navigate(MainNavigationScreens.Archive.path()) } + ) } - composable(MainNavigationScreens.Messages.route) { - MessageScreen(mainNavController, messageVM) + composable(MainNavigationScreens.Orders.route) { + OrderScreen( + mainNavController = mainNavController, + mainScreenViewModel = mainScreenViewModel, + onElevateTopBar = { + topBarElevated = it + } + ) + } + composable( + MainNavigationScreens.Settings.route, + MainNavigationScreens.Settings.arguments + ) { + SettingsScreen( + mainNavController = mainNavController, + settingsViewModel = settingsViewModel + ) } } } @@ -473,26 +746,30 @@ fun MainScreenWithScaffold( } @Composable -private fun MainScreenBottomNavigation( +fun MainScreenBottomNavigation( navController: NavController, bottomNavController: NavController, - viewModel: MainScreenViewModel = hiltViewModel(LocalActivity.current) + viewModel: MainScreenViewModel, + profileId: ProfileIdentifier ) { val navBackStackEntry by bottomNavController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - val unreadMessagesAvailable by viewModel.unreadMessagesAvailable() + val unreadMessagesAvailable by viewModel.unreadMessagesAvailable(profileId) .collectAsState(initial = false) - BottomNavigation(backgroundColor = MaterialTheme.colors.surface) { + BottomNavigation( + backgroundColor = MaterialTheme.colors.surface, + extraContent = {} + ) { MainScreenBottomNavigationItems.forEach { screen -> BottomNavigationItem( modifier = Modifier.testTag( when (screen) { - MainNavigationScreens.Prescriptions -> "erx_btn_prescriptions" - MainNavigationScreens.Messages -> "erx_btn_messages" - MainNavigationScreens.Pharmacies -> "erx_btn_search_pharmacies" - MainNavigationScreens.Settings -> "erx_btn_settings" + MainNavigationScreens.Prescriptions -> TestTag.BottomNavigation.PrescriptionButton + MainNavigationScreens.Orders -> TestTag.BottomNavigation.OrdersButton + MainNavigationScreens.Pharmacies -> TestTag.BottomNavigation.PharmaciesButton + MainNavigationScreens.Settings -> TestTag.BottomNavigation.SettingsButton else -> "" } ), @@ -505,15 +782,20 @@ private fun MainScreenBottomNavigation( null, modifier = Modifier.size(24.dp) ) - MainNavigationScreens.Messages -> Icon( + + MainNavigationScreens.Orders -> Icon( if (unreadMessagesAvailable) Icons.Outlined.MarkChatUnread else Icons.Outlined.MarkChatRead, null ) + MainNavigationScreens.Pharmacies -> Icon( - Icons.Outlined.Search, contentDescription = null + Icons.Outlined.Search, + contentDescription = null ) + MainNavigationScreens.Settings -> Icon( - Icons.Outlined.Settings, contentDescription = null + Icons.Outlined.Settings, + contentDescription = null ) } }, @@ -522,7 +804,7 @@ private fun MainScreenBottomNavigation( stringResource( when (screen) { MainNavigationScreens.Prescriptions -> R.string.pres_bottombar_prescriptions - MainNavigationScreens.Messages -> R.string.pres_bottombar_messages + MainNavigationScreens.Orders -> R.string.pres_bottombar_orders MainNavigationScreens.Pharmacies -> R.string.pres_bottombar_pharmacies MainNavigationScreens.Settings -> R.string.main_settings_acc else -> R.string.pres_bottombar_prescriptions @@ -536,12 +818,12 @@ private fun MainScreenBottomNavigation( alwaysShowLabel = true, onClick = { if (currentRoute != screen.route) { - if (screen.route == MainNavigationScreens.Pharmacies.route || - screen.route == MainNavigationScreens.Settings.route - ) { - navController.navigate(screen.path()) - } else { - bottomNavController.navigate(screen.path()) + when (screen.route) { + MainNavigationScreens.Pharmacies.route -> + navController.navigate(screen.path()) + + else -> + bottomNavController.navigate(screen.path()) } } } @@ -550,324 +832,258 @@ private fun MainScreenBottomNavigation( } } -@Preview(showBackground = true) @Composable -fun TopAppBarMultiUserPreview() { - AppTheme { - TopAppBarMultiUser(mainScreenViewModel = hiltViewModel(LocalActivity.current), {}, {}) +fun MainScreenTopBarTitle(isInPrescriptionScreen: Boolean) { + val text = if (isInPrescriptionScreen) { + stringResource(R.string.pres_bottombar_prescriptions) + } else { + stringResource(R.string.orders_title) } + Text( + text = text, + style = AppTheme.typography.h5, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } @Composable -fun TopAppBarMultiUser( +fun ProfilesChipBar( mainScreenViewModel: MainScreenViewModel, - onClickEdit: (Int) -> Unit, - onClickEditProfiles: () -> Unit + onClickAddProfile: () -> Unit, + onClickChangeProfileName: (profile: ProfilesUseCaseData.Profile) -> Unit ) { + val profileHandler = LocalProfileHandler.current + val profiles = profileHandler.profiles.value + val scope = rememberCoroutineScope() + val rowState = rememberLazyListState() - val profileList by produceState( - initialValue = listOf( - ProfilesUseCaseData.Profile( - id = 0, - name = "", - active = true, - color = ProfileColorNames.SPRING_GRAY, - insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation() - ) - ) - ) { - mainScreenViewModel.profileUiState().collect { value = it } - } - - val activeProfile = profileList.find { - it.active - }!! + var indexOfActiveProfile by remember { mutableStateOf(0) } - val lastAuthenticatedDate = remember(activeProfile) { - activeProfile.lastAuthenticated?.let { - dateTimeShortText(it) - } + LaunchedEffect(indexOfActiveProfile) { + delay(timeMillis = 300L) + rowState.animateScrollToItem(indexOfActiveProfile) } - val activeProfileName = activeProfile.name - val activeProfileColor = profileColor(activeProfile.color) - val ssoToken = activeProfile.ssoToken - val ssoText = connectionText(ssoToken, lastAuthenticatedDate) - val ssoTextColor = connectionTextColor(profileSsoToken = ssoToken) - val ssoStatusColor = ssoStatusColor(activeProfile, ssoToken) - - var expanded by remember { mutableStateOf(false) } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, + LazyRow( + state = rowState, + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), modifier = Modifier - .wrapContentWidth() - .clip(CircleShape) - .clickable { expanded = !expanded } - .padding(PaddingDefaults.Tiny) + .fillMaxWidth() + .padding(top = PaddingDefaults.Medium, bottom = PaddingDefaults.Small), + verticalAlignment = Alignment.CenterVertically ) { - Avatar(activeProfileName, activeProfileColor, ssoStatusColor) - Column( - Modifier.padding( - start = PaddingDefaults.Small + PaddingDefaults.Tiny, - end = PaddingDefaults.Medium - ) - ) { - - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = activeProfileName, - style = MaterialTheme.typography.subtitle1, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Icon( - imageVector = Icons.Rounded.ArrowDropDown, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + item { + SpacerSmall() + } + profiles.forEachIndexed { index, profile -> + if (profile.id == profileHandler.activeProfile.id) { + indexOfActiveProfile = index + 1 } - Text( - text = ssoText, - color = ssoTextColor, - style = AppTheme.typography.captionl - ) - - if (expanded) { - ProfileSelector( - onClickEdit = onClickEdit, - onClickEditProfiles = onClickEditProfiles, - onClickProfile = { mainScreenViewModel.saveActiveProfile(it) }, - userList = profileList, - onDismiss = { expanded = false }, + item { + ProfileChip( + profile = profile, + mainScreenViewModel = mainScreenViewModel, + selected = profile.id == profileHandler.activeProfile.id, + onClickChip = { scope.launch { profileHandler.switchActiveProfile(profile) } }, + onClickChangeProfileName = onClickChangeProfileName ) + SpacerSmall() + } + } + item { + AddProfileChip { + onClickAddProfile() } + SpacerMedium() } } } @Composable -private fun ssoStatusColor(profile: ProfilesUseCaseData.Profile, ssoToken: SingleSignOnToken?) = - when { - ssoToken?.isValid() == true -> AppTheme.colors.green400 - profile.lastAuthenticated != null -> AppTheme.colors.red400 - else -> null +fun AddProfileChip(onClickAddProfile: () -> Unit) { + val shape = RoundedCornerShape(8.dp) + + Surface( + modifier = Modifier + .clip(shape) + .clickable { + onClickAddProfile() + } + .height(IntrinsicSize.Max), + shape = shape, + border = BorderStroke(1.dp, AppTheme.colors.neutral300) + ) { + Row( + modifier = Modifier.padding(vertical = 6.dp, horizontal = PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Rounded.PersonAdd, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.primary600 + ) + } } +} +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ProfileSelector( - onClickEdit: (Int) -> Unit, - onClickEditProfiles: () -> Unit, - onClickProfile: (ProfilesUseCaseData.Profile) -> Unit, - userList: List, - onDismiss: () -> Unit +fun ProfileChip( + profile: ProfilesUseCaseData.Profile, + selected: Boolean, + mainScreenViewModel: MainScreenViewModel, + onClickChip: (ProfileIdentifier) -> Unit, + onClickChangeProfileName: (profile: ProfilesUseCaseData.Profile) -> Unit ) { + val refreshPrescriptionsController = rememberRefreshPrescriptionsController(mainScreenViewModel) - val dismissModifier = - Modifier.clickable( - onClick = onDismiss, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) + val isRefreshing by refreshPrescriptionsController.isRefreshing + var refreshEvent by remember { mutableStateOf(null) } - Dialog( - onDismissRequest = onDismiss, - ) { - Box( - Modifier - .semantics(false) { } - .fillMaxSize() - .then(dismissModifier) - .background(SolidColor(Color.Black), alpha = 0.5f) - .systemBarsPadding(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = Modifier - .wrapContentHeight() - .fillMaxWidth() - .padding(PaddingDefaults.Medium), - color = MaterialTheme.colors.surface, - shape = RoundedCornerShape(28.dp), - elevation = 8.dp - ) { + LaunchedEffect(Unit) { + mainScreenViewModel.onRefreshEvent.collect { + refreshEvent = it + } + } - AnimatedVisibility( - visibleState = remember { MutableTransitionState(false) }.apply { - targetState = true - }, - enter = expandVertically() + fadeIn(), - exit = ExitTransition.None + var iconVisible by remember { mutableStateOf(false) } - ) { - Box() { - Column(modifier = Modifier.padding(bottom = 56.dp)) { - Row(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(R.string.select_profile), - style = MaterialTheme.typography.body2, - color = AppTheme.colors.neutral600, - modifier = Modifier - .padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = PaddingDefaults.Medium, - bottom = PaddingDefaults.Medium / 2 - ) - .weight(1f) - .testTag("ProfileSelector") - ) - IconButton(onClick = { onDismiss() }) { - Icon( - imageVector = Icons.Rounded.Close, - tint = AppTheme.colors.primary600, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - } + val coroutineScope = rememberCoroutineScope() - val listState = rememberLazyListState() - - LazyColumn( - modifier = Modifier.testTag("profileList"), - state = listState - ) { - userList.forEach { - item { - ProfileCard( - profile = it, - onClickEdit = onClickEdit, - onClickProfile = onClickProfile, - onDismiss = onDismiss - ) - } - } - } - } + val ssoTokenScope = profile.ssoTokenScope - Column(modifier = Modifier.align(Alignment.BottomCenter)) { - Divider(color = AppTheme.colors.neutral300) - TextButton( - onClick = { onClickEditProfiles() }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - ) { - Text(text = stringResource(R.string.edit_profiles)) - } - } - } - } + LaunchedEffect(Unit) { + ssoTokenScope?.token?.let { + if (!it.isValid()) { + iconVisible = true + delay(StateVisibilityTime) + iconVisible = false } } } -} -@Composable -fun ProfileCard( - profile: ProfilesUseCaseData.Profile, - onClickEdit: (Int) -> Unit, - onClickProfile: (profile: ProfilesUseCaseData.Profile) -> Unit, - onDismiss: () -> Unit -) { - val colors = profileColor(profileColorNames = profile.color) - val profileSsoToken = profile.ssoToken + LaunchedEffect(key1 = refreshEvent, key2 = isRefreshing) { + iconVisible = true + delay(StateVisibilityTime) + iconVisible = false + } - Row( + val icon = when { + ssoTokenScope?.token?.isValid() == true -> Icons.Rounded.CloudDone + else -> Icons.Outlined.CloudOff + } + + val color = if (refreshEvent is PrescriptionServiceErrorState) { + AppTheme.colors.neutral600 + } else { + ssoStatusColor(profile, ssoTokenScope) ?: AppTheme.colors.neutral400 + } + + val configuration = LocalConfiguration.current + val maxChipWidth = (configuration.screenWidthDp.dp) * Third + + val shape = RoundedCornerShape(8.dp) + + val backgroundColor = if (selected) { + AppTheme.colors.neutral100 + } else { + AppTheme.colors.neutral025 + } + val textColor = if (selected) { + AppTheme.colors.neutral900 + } else { + AppTheme.colors.neutral600 + } + val borderColor = if (selected) { + AppTheme.colors.neutral300 + } else { + AppTheme.colors.neutral200 + } + + val description = stringResource(R.string.mainscreen_profile_chip_content_description) + + Surface( modifier = Modifier - .wrapContentHeight() - .fillMaxWidth() - .clickable { - onClickProfile(profile) - onDismiss() + .clip(shape) + .combinedClickable( + onClick = { onClickChip(profile.id) }, + onLongClick = { onClickChangeProfileName(profile) }, + role = Role.Button + ) + .widthIn(max = maxChipWidth) + .width(IntrinsicSize.Max) + .semantics { + contentDescription = description }, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + shape = shape, + border = BorderStroke(1.dp, borderColor), + color = backgroundColor ) { Row( modifier = Modifier - .weight(1f) - .padding(PaddingDefaults.Medium) + .padding(vertical = 8.dp, horizontal = PaddingDefaults.ShortMedium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Avatar(profile.name, colors, null, active = profile.active) - - SpacerSmall() - - Column { - Text( - profile.name, style = MaterialTheme.typography.body1, - ) - - val lastAuthenticatedDateText = - remember(profile.lastAuthenticated) { - profile.lastAuthenticated?.let { - dateTimeShortText( - it - ) - } - } - val connectedText = connectionText(profileSsoToken, lastAuthenticatedDateText) - val connectedColor = connectionTextColor(profileSsoToken) - - Text( - connectedText, style = AppTheme.typography.captionl, - color = connectedColor, - ) - } - } + Text( + text = profile.name, + style = AppTheme.typography.subtitle2, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) - TextButton( - onClick = { - onClickEdit(profile.id) + if (profile.lastAuthenticated != null && selected) { + AnimatedVisibility( + visible = iconVisible, + enter = fadeIn(animationSpec = tween(TweenDuration)), + exit = fadeOut(animationSpec = tween(TweenDuration)) + ) { + Icon( + imageVector = icon, + modifier = Modifier + .size(16.dp), + contentDescription = null, + tint = color + ) + } } - ) { - Text(text = "Details") } - - SpacerTiny() } } +@Composable +fun ssoStatusColor(profile: ProfilesUseCaseData.Profile, ssoTokenScope: IdpData.SingleSignOnTokenScope?) = + when { + ssoTokenScope?.token?.isValid() == true -> AppTheme.colors.green400 + profile.lastAuthenticated != null -> AppTheme.colors.neutral400 + else -> null + } + /** * The top appbar of the actual main screen. */ @Composable fun MultiProfileTopAppBar( navController: NavController, - mainScreenVieModel: MainScreenViewModel + mainScreenViewModel: MainScreenViewModel, + isInPrescriptionScreen: Boolean, + elevated: Boolean, + onClickAddProfile: () -> Unit, + onClickChangeProfileName: (profile: ProfilesUseCaseData.Profile) -> Unit ) { val accScan = stringResource(R.string.main_scan_acc) - val context = LocalContext.current - val demoToastText = stringResource(R.string.function_not_availlable_on_demo_mode) - - TopAppBar( + val elevation = remember(elevated) { if (elevated) AppBarDefaults.TopAppBarElevation else 0.dp } + TopAppBarWithContent( title = { - TopAppBarMultiUser( - mainScreenVieModel, - onClickEditProfiles = { - navController.navigate( - MainNavigationScreens.Settings.path( - SettingsScrollTo.Profiles - ) - ) - }, - onClickEdit = { - if (mainScreenVieModel.isDemoActive()) { - createToastShort(context, demoToastText) - } else { - navController.navigate(MainNavigationScreens.EditProfile.path(it)) - } - } - ) + MainScreenTopBarTitle(isInPrescriptionScreen) }, - elevation = 8.dp, - backgroundColor = MaterialTheme.colors.surface, + elevation = elevation, + backgroundColor = AppTheme.colors.neutral025, actions = @Composable { var showMlKitPermissionDialog by remember { mutableStateOf(false) } @@ -883,30 +1099,40 @@ fun MultiProfileTopAppBar( ) } - // data matrix code scanner - IconButton( - onClick = { - if (!isMlKitInitialized()) { - showMlKitPermissionDialog = true - } else { - navController.navigate(MainNavigationScreens.Camera.path()) - } - }, - modifier = Modifier - .testId("erx_btn_scn_prescription") - .semantics { contentDescription = accScan } - ) { - Icon( - Icons.Rounded.QrCode, null, - tint = AppTheme.colors.primary700, - modifier = Modifier.size(24.dp) - ) + if (isInPrescriptionScreen) { + // data matrix code scanner + IconButton( + onClick = { + if (!isMlKitInitialized()) { + showMlKitPermissionDialog = true + } else { + navController.navigate(MainNavigationScreens.Camera.path()) + } + }, + modifier = Modifier + .testTag("erx_btn_scn_prescription") + .semantics { contentDescription = accScan } + ) { + Icon( + imageVector = Icons.Rounded.AddCircle, + contentDescription = null, + tint = AppTheme.colors.primary700, + modifier = Modifier.size(24.dp) + ) + } } + }, + content = { + ProfilesChipBar( + mainScreenViewModel = mainScreenViewModel, + onClickAddProfile = onClickAddProfile, + onClickChangeProfileName = onClickChangeProfileName + ) } ) } -private fun isMlKitInitialized() = +fun isMlKitInitialized() = try { MlKitContext.getInstance() true @@ -915,7 +1141,7 @@ private fun isMlKitInitialized() = } @Composable -private fun MlKitPermissionDialog( +fun MlKitPermissionDialog( onAccept: () -> Unit, onDecline: () -> Unit ) { diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt index 86721473..7df2247f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenNavigationScreens.kt @@ -21,77 +21,60 @@ package de.gematik.ti.erp.app.mainscreen.ui import android.os.Parcelable import androidx.navigation.NavType import androidx.navigation.navArgument -import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable import de.gematik.ti.erp.app.AppNavTypes import de.gematik.ti.erp.app.Route -import de.gematik.ti.erp.app.settings.ui.SettingsScrollTo +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.parcelize.Parcelize @Parcelize -@JsonClass(generateAdapter = true) +@Serializable data class TaskIds(val ids: List) : Parcelable, List by ids object MainNavigationScreens { object Onboarding : Route("Onboarding") - object ProfileSetup : Route("ProfileSetup") object ReturningUserSecureAppOnboarding : Route("ReturningUserSecureAppOnboarding") - object Settings : Route( - "Settings", - navArgument("scrollToSection") { - type = NavType.EnumType(SettingsScrollTo::class.java) - defaultValue = SettingsScrollTo.None - } - ) { - fun path(scrollToSection: SettingsScrollTo = SettingsScrollTo.None) = - path("scrollToSection" to scrollToSection) - } - + object Biometry : Route("Biometry") + object Settings : Route("Settings") object Camera : Route("Camera") object Prescriptions : Route("Prescriptions") + object Archive : Route("Archive") + object PrescriptionDetail : - Route("PrescriptionDetail", navArgument("taskId") { type = NavType.StringType }) { + Route( + "PrescriptionDetail", + navArgument("taskId") { type = NavType.StringType } + ) { fun path(taskId: String) = path("taskId" to taskId) } - object Messages : Route("Messages") - object PickUpCode : Route( - "PickUpCode", - navArgument("pickUpCodeHR") { - type = NavType.StringType - nullable = true - }, - navArgument("pickUpCodeDMC") { - type = NavType.StringType - nullable = true - } - ) { - fun path(pickUpCodeHR: String?, pickUpCodeDMC: String?) = - path("pickUpCodeHR" to pickUpCodeHR, "pickUpCodeDMC" to pickUpCodeDMC) - } + object Orders : Route("Orders") - object Pharmacies : Route( - "Pharmacies", - navArgument("taskIds") { - type = AppNavTypes.TaskIdsType - defaultValue = TaskIds(emptyList()) - } + object Messages : Route( + "Messages", + navArgument("orderId") { type = NavType.StringType } ) { - fun path(taskIds: TaskIds) = path("taskIds" to taskIds) + fun path(orderId: String) = + Messages.path("orderId" to orderId) } + object Pharmacies : Route("Pharmacies") + object RedeemLocally : Route("RedeemLocally", navArgument("taskIds") { type = AppNavTypes.TaskIdsType }) { fun path(taskIds: TaskIds) = path("taskIds" to taskIds) } + object ProfileImageCropper : Route("ProfileImageCropper", navArgument("profileId") { type = NavType.StringType }) { + fun path(profileId: String) = path("profileId" to profileId) + } + object CardWall : Route( "CardWall", - navArgument("can") { - type = NavType.BoolType - defaultValue = false - } + navArgument("profileId") { type = NavType.StringType } ) { - fun path(canAvailable: Boolean) = path("can" to canAvailable) + fun path(profileId: ProfileIdentifier) = path("profileId" to profileId) } object InsecureDeviceScreen : Route("InsecureDeviceScreen") @@ -99,14 +82,26 @@ object MainNavigationScreens { object DataProtection : Route("DataProtection") object SafetynetNotOkScreen : Route("SafetynetInfoScreen") object EditProfile : - Route("EditProfile", navArgument("profileId") { type = NavType.IntType }) { - fun path(profileId: Int) = path("profileId" to profileId) + Route("EditProfile", navArgument("profileId") { type = NavType.StringType }) { + fun path(profileId: String) = path("profileId" to profileId) + } + object Terms : Route("Terms") + object Imprint : Route("Imprint") + object OpenSourceLicences : Route("OpenSourceLicences") + object AdditionalLicences : Route("AdditionalLicences") + object AllowAnalytics : Route("AcceptAnalytics") + object Password : Route("Password") + object Debug : Route("Debug") + object OrderHealthCard : Route("OrderHealthCard") + + object UnlockEgk : Route("UnlockEgk", navArgument("unlockMethod") { type = NavType.StringType }) { + fun path(unlockMethod: UnlockMethod) = path("unlockMethod" to unlockMethod.name) } } val MainScreenBottomNavigationItems = listOf( MainNavigationScreens.Prescriptions, - MainNavigationScreens.Messages, + MainNavigationScreens.Orders, MainNavigationScreens.Pharmacies, MainNavigationScreens.Settings ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenOrderSuccessDialog.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenOrderSuccessDialog.kt new file mode 100644 index 00000000..eb2ceb11 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenOrderSuccessDialog.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.mainscreen.ui + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import com.google.android.play.core.review.ReviewManagerFactory +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.pharmacy.ui.VideoContent +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData.OrderOption.CourierDelivery +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData.OrderOption.MailDelivery +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData.OrderOption.ReserveInPharmacy +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.Dialog +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall + +const val OrderSuccessVideoAspectRatio = 1.69f + +@Composable +fun OrderSuccessDialog( + mainScreenVM: MainScreenViewModel +) { + val action by produceState(initialValue = null) { + mainScreenVM.successFullyOrderedEvent.collect { + value = it as? ActionEvent.ReturnFromPharmacyOrder + } + } + + val context = LocalContext.current + + var showDialog by remember(action) { mutableStateOf(action != null) } + + if (action != null && showDialog) { + fun requestInAppReview() { + requestReview(context) + mainScreenVM.resetSuccessFullyOrderedEvent() + showDialog = false + } + Dialog( + onDismissRequest = { + requestInAppReview() + }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + Box( + Modifier + .semantics(false) { } + .fillMaxSize() + .background(SolidColor(Color.Black), alpha = 0.5f) + .systemBarsPadding() + .clickable( + onClick = { + requestInAppReview() + }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .testTag(TestTag.Main.OrderSuccessDialog.Modal), + contentAlignment = Alignment.BottomCenter + ) { + var delayedVisibility by remember { mutableStateOf(false) } + LaunchedEffect(showDialog) { + delayedVisibility = showDialog + } + AnimatedVisibility( + delayedVisibility, + enter = slideInVertically { it }, + exit = slideOutVertically { it } + ) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .wrapContentHeight() + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(28.dp), + elevation = 8.dp + ) { + Column { + VideoContent( + Modifier.fillMaxWidth(), + source = when (action!!.successfullyOrdered) { + ReserveInPharmacy -> R.raw.animation_local + CourierDelivery -> R.raw.animation_courier + MailDelivery -> R.raw.animation_mail + }, + aspectRatioOverwrite = OrderSuccessVideoAspectRatio + ) + SpacerMedium() + Column( + Modifier.padding(horizontal = PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.main_order_success_title), + textAlign = TextAlign.Center, + style = AppTheme.typography.h6 + ) + SpacerSmall() + Text( + stringResource(R.string.main_order_success_subtitle), + textAlign = TextAlign.Center, + style = AppTheme.typography.body1 + ) + TextButton( + onClick = { + requestInAppReview() + }, + modifier = Modifier + .padding(PaddingDefaults.Large) + .align(Alignment.End) + .testTag(TestTag.Main.OrderSuccessDialog.DismissButton) + ) { + Text(stringResource(R.string.main_order_success_close)) + } + } + } + } + } + } + } + } +} + +private fun requestReview(context: Context) { + val manager = ReviewManagerFactory.create(context) + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + val reviewInfo = task.result + manager.launchReviewFlow(context as Activity, reviewInfo) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenSnackbar.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenSnackbar.kt index f13dc35f..87643925 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenSnackbar.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenSnackbar.kt @@ -28,16 +28,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.ui.GenerellErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.prescription.ui.RefreshedState import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource -import kotlinx.coroutines.flow.collect @Composable fun MainScreenSnackbar( mainScreenViewModel: MainScreenViewModel, - scaffoldState: ScaffoldState, + scaffoldState: ScaffoldState ) { - - var refreshEvent by remember { mutableStateOf(null) } + var refreshEvent by remember { mutableStateOf(null) } LaunchedEffect(Unit) { mainScreenViewModel.onRefreshEvent.collect { refreshEvent = it @@ -46,13 +47,13 @@ fun MainScreenSnackbar( val refreshEventText = refreshEvent?.let { when (it) { - RefreshEvent.NetworkNotAvailable -> + GenerellErrorState.NetworkNotAvailable -> stringResource(R.string.error_message_network_not_available) - is RefreshEvent.ServerCommunicationFailedWhileRefreshing -> + is GenerellErrorState.ServerCommunicationFailedWhileRefreshing -> stringResource(R.string.error_message_server_communication_failed).format(it.code) - RefreshEvent.FatalTruststoreState -> + GenerellErrorState.FatalTruststoreState -> stringResource(R.string.error_message_vau_error) - is RefreshEvent.NewPrescriptionsEvent -> { + is RefreshedState -> { if (it.nrOfNewPrescriptions == 0) { stringResource(R.string.zero_prescriptions_updatet) } else { @@ -63,6 +64,7 @@ fun MainScreenSnackbar( ) } } + else -> "" } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenViewModel.kt index 1cdc03fe..6ab4c1fc 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenViewModel.kt @@ -18,115 +18,52 @@ package de.gematik.ti.erp.app.mainscreen.ui -import android.net.Uri import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken -import de.gematik.ti.erp.app.idp.usecase.IdpUseCase -import de.gematik.ti.erp.app.mainscreen.ui.model.MainScreenData -import de.gematik.ti.erp.app.messages.usecase.MessageUseCase -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.orders.usecase.OrderUseCase +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import timber.log.Timber -import java.time.Duration -import java.time.Instant +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull -data class RedeemEvent( - val taskIds: List, - val isFullDetail: Boolean -) - -sealed class RefreshEvent { - object NetworkNotAvailable : RefreshEvent() - data class ServerCommunicationFailedWhileRefreshing(val code: Int) : RefreshEvent() - object FatalTruststoreState : RefreshEvent() - data class NewPrescriptionsEvent(val nrOfNewPrescriptions: Int) : RefreshEvent() -} - -enum class PullRefreshState { - None, - HasFirstTimeValidToken, - IsFirstTimeBiometricAuthentication, - HasValidToken +/** + * Event used to indicate an action that should be visible to the user on main screen. + */ +sealed class ActionEvent { + data class ReturnFromPharmacyOrder(val successfullyOrdered: PharmacyScreenData.OrderOption) : ActionEvent() } -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class MainScreenViewModel @Inject constructor( - private val demoUseCase: DemoUseCase, - private val messageUseCase: MessageUseCase, - private val prescriptionUseCase: PrescriptionUseCase, - private val profileUseCase: ProfilesUseCase, - private val coroutineDispatchProvider: DispatchProvider, - private val idpUseCase: IdpUseCase, -) : BaseViewModel() { +class MainScreenViewModel( + private val messageUseCase: OrderUseCase, + private val dispatchers: DispatchProvider +) : ViewModel() { - private val _onRefreshEvent = MutableSharedFlow() - val onRefreshEvent: Flow + private val _onRefreshEvent = MutableSharedFlow() + val onRefreshEvent: Flow get() = _onRefreshEvent - fun profileUiState() = profileUseCase.profiles.flowOn(coroutineDispatchProvider.unconfined()) - - fun refreshState(): Flow = profileUiState() - .map { - val activeProfile = it.find { profile -> profile.active }!! - - val ssoToken = activeProfile.ssoToken - val now = Instant.now() - when { - ssoToken is SingleSignOnToken.AlternateAuthenticationWithoutToken -> PullRefreshState.IsFirstTimeBiometricAuthentication - ssoToken != null && ssoToken.validOn in (now - Duration.ofSeconds(5))..(now) -> PullRefreshState.HasFirstTimeValidToken - ssoToken != null && ssoToken.isValid(now) -> PullRefreshState.HasValidToken - else -> PullRefreshState.None - } - } + private val _onSuccessFullyOrderedEvent = MutableStateFlow(null) + val successFullyOrderedEvent: Flow + get() = _onSuccessFullyOrderedEvent.filterNotNull() - fun redeemState(): Flow = - combine( - prescriptionUseCase.unredeemedSyncedTaskIds(), - prescriptionUseCase.unredeemedScannedTaskIds() - ) { syncedTaskIds, scannedTaskIds -> - MainScreenData.RedeemState( - scannedTaskIds = TaskIds(ids = scannedTaskIds), syncedTaskIds = TaskIds(ids = syncedTaskIds) - ) - } - - fun saveActiveProfile(profile: ProfilesUseCaseData.Profile) { - viewModelScope.launch { profileUseCase.switchActiveProfile(profile) } + fun resetSuccessFullyOrderedEvent() { + _onSuccessFullyOrderedEvent.value = null } + fun unreadMessagesAvailable(profileIdentifier: ProfileIdentifier) = + messageUseCase.unreadCommunicationsAvailable(profileIdentifier) - fun unreadMessagesAvailable() = - messageUseCase.unreadCommunicationsAvailable(CommunicationProfile.ErxCommunicationReply) - - suspend fun onRefresh(event: RefreshEvent) { + suspend fun onRefresh(event: PrescriptionServiceState) { _onRefreshEvent.emit(event) } - fun onDeactivateDemoMode() { - demoUseCase.deactivateDemoMode() - } - - fun isDemoActive(): Boolean = demoUseCase.isDemoModeActive - - fun onExternAppAuthorizationResult(uri: Uri) { - Timber.d(uri.toString()) - viewModelScope.launch { - idpUseCase.authenticateWithExternalAppAuthorization(uri) - prescriptionUseCase.downloadTasks(profileUseCase.activeProfileName().first()) + fun onSuccessfullyOrdered(event: ActionEvent) { + viewModelScope.launch(dispatchers.Default) { + _onSuccessFullyOrderedEvent.emit(event) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemButton.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemButton.kt new file mode 100644 index 00000000..2b5780ad --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemButton.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.mainscreen.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.spring +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun RedeemFloatingActionButton( + visible: Boolean, + onClick: () -> Unit +) { + AnimatedVisibility( + visible = visible, + enter = scaleIn(), + exit = scaleOut(spring()) + ) { + ExtendedFloatingActionButton( + modifier = Modifier.heightIn(min = 56.dp), + text = { Text(stringResource(R.string.main_redeem_button)) }, + shape = RoundedCornerShape(16.dp), + icon = { Icon(Icons.Rounded.Upload, null) }, + onClick = onClick + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RefreshScaffold.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RefreshScaffold.kt new file mode 100644 index 00000000..405524c6 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RefreshScaffold.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.mainscreen.ui + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.SwipeRefreshIndicator +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import de.gematik.ti.erp.app.prescription.ui.rememberRefreshPrescriptionsController +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.theme.AppTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val SpinnerDelay = 300L + +@Composable +fun RefreshScaffold( + profileId: ProfileIdentifier, + onUserNotAuthenticated: () -> Unit, + mainScreenViewModel: MainScreenViewModel, + onShowCardWall: () -> Unit, + content: @Composable (onRefresh: (isUserAction: Boolean, priority: MutatePriority) -> Unit) -> Unit +) { + val scope = rememberCoroutineScope() + val mutex = MutatorMutex() + + val refreshPrescriptionsController = rememberRefreshPrescriptionsController(mainScreenViewModel) + + val isRefreshing by refreshPrescriptionsController.isRefreshing + val refreshState = rememberSwipeRefreshState(isRefreshing) + + suspend fun refresh( + isUserAction: Boolean, + profileId: ProfileIdentifier, + priority: MutatePriority = MutatePriority.Default + ) { + if (refreshState.isRefreshing) { + return + } + mutex.mutate(priority) { + refreshState.isRefreshing = true + delay(SpinnerDelay) // required for the spinner + + refreshPrescriptionsController.refresh( + profileId = profileId, + isUserAction = isUserAction, + onUserNotAuthenticated = onUserNotAuthenticated, + onShowCardWall = { + if (isUserAction) { + scope.launch(Dispatchers.Main) { + onShowCardWall() + } + } + } + ) + } + } + + LaunchedEffect(profileId) { + // refresh on a profile change + refresh(isUserAction = false, profileId = profileId) + } + + SwipeRefresh( + state = refreshState, + modifier = Modifier.fillMaxSize(), + onRefresh = { + scope.launch { refresh(isUserAction = true, priority = MutatePriority.UserInput, profileId = profileId) } + }, + indicator = { s, trigger -> + SwipeRefreshIndicator( + state = s, + refreshTriggerDistance = trigger, + contentColor = AppTheme.colors.primary600 + ) + }, + swipeEnabled = true + ) { + content { isUserAction, priority -> + scope.launch { refresh(isUserAction = isUserAction, priority = priority, profileId = profileId) } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/TopBars.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/TopBars.kt new file mode 100644 index 00000000..24c5a57d --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/TopBars.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.mainscreen.ui + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Tab +import androidx.compose.material.TabPosition +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R.string +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults + +// If updated: also add corresponding string to the tabNames-List below +@Stable +enum class PrescriptionTabs(val index: Int) { + Redeemable(0), Archive(1); + + companion object { + fun ofValue(index: Int): PrescriptionTabs? = values().find { it.index == index } + } +} + +@Composable +fun RedeemAndArchiveTabs( + selectedTab: PrescriptionTabs, + onSelectedTab: (PrescriptionTabs) -> Unit +) { + val tabNames = listOf(stringResource(string.mainscreen_tab_redeemable), stringResource(string.mainscreen_tab_archive)) + + TextTabRow( + selectedTabIndex = selectedTab.index, + modifier = Modifier.fillMaxWidth(), + tabs = tabNames, + backGroundColor = MaterialTheme.colors.surface, + onClick = { onSelectedTab(PrescriptionTabs.ofValue(it)!!) } + ) +} + +@Composable +fun TextTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + onClick: (index: Int) -> Unit, + backGroundColor: Color, + tabs: List, + testTags: (List)? = null +) { + var contentWidth by remember { mutableStateOf(0) } + + TabRow( + modifier = modifier, + selectedTabIndex = selectedTabIndex, + backgroundColor = backGroundColor, + indicator = { tabPositions -> + TabIndicator(tabPositions, selectedTabIndex, with(LocalDensity.current) { contentWidth.toDp() }) + }, + divider = {} + ) { + tabs.forEachIndexed { tabIndex: Int, tabText: String -> + Tab( + modifier = testTags?.let { Modifier.testTag(testTags[tabIndex]) } ?: Modifier, + selected = tabIndex == selectedTabIndex, + onClick = { onClick(tabIndex) }, + selectedContentColor = AppTheme.colors.primary700, + unselectedContentColor = AppTheme.colors.neutral500 + ) { + Text( + text = tabText, + style = AppTheme.typography.subtitle2, + modifier = Modifier + .padding(top = PaddingDefaults.Small) + .padding(bottom = PaddingDefaults.Small + 2.dp) + .align(Alignment.CenterHorizontally) + .wrapContentWidth() + .onSizeChanged { size -> contentWidth = size.width } + ) + } + } + } +} + +@Preview +@Composable +private fun RedeemAndArchiveTabsPreview() { + AppTheme { + RedeemAndArchiveTabs(PrescriptionTabs.Redeemable, {}) + } +} + +@Composable +private fun TabIndicator(tabPositions: List, selectedTab: Int, contentWidth: Dp) { + val currentContentWidth by animateDpAsState( + targetValue = contentWidth, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) + val indicatorOffset by animateDpAsState( + targetValue = tabPositions[selectedTab].left + (tabPositions[selectedTab].width - contentWidth) / 2, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) + + Box( + modifier = Modifier.wrapContentSize(Alignment.BottomStart) + .offset(indicatorOffset) + .width(currentContentWidth) + .clip(RoundedCornerShape(2.dp)) + .height(2.dp) + .background(color = AppTheme.colors.primary700) + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/messages/repository/MessageRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/messages/repository/MessageRepository.kt deleted file mode 100644 index 4b0a7def..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/messages/repository/MessageRepository.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.repository - -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.prescription.repository.LocalDataSource -import javax.inject.Inject - -class MessageRepository @Inject constructor( - private val localDataSource: LocalDataSource -) { - - fun loadCommunications(profile: CommunicationProfile, profileName: String) = - localDataSource.loadCommunications(profile, profileName) - - fun loadUnreadCommunications(profile: CommunicationProfile, profileName: String) = - localDataSource.loadUnreadCommunications(profile, profileName) - - suspend fun setCommunicationAcknowledgedStatus(communicationId: String, consumed: Boolean) { - localDataSource.setCommunicationsAcknowledgedStatus(communicationId, consumed) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/DisplayPickupCodeComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/messages/ui/DisplayPickupCodeComponents.kt deleted file mode 100644 index 97b045f8..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/DisplayPickupCodeComponents.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import de.gematik.ti.erp.app.utils.compose.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.redeem.ui.DataMatrixCode -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.NavigationClose -import de.gematik.ti.erp.app.utils.compose.Spacer32 -import de.gematik.ti.erp.app.utils.compose.Spacer4 -import de.gematik.ti.erp.app.utils.compose.Spacer8 - -@Composable -fun DisplayPickupScreen( - mainNavController: NavController, - pickupCodeHR: String?, - pickupCodeDMC: String?, - viewModel: MessageViewModel = hiltViewModel() -) { - Scaffold( - topBar = { - TopAppBar( - backgroundColor = Color.Unspecified, - title = { - Text(stringResource(R.string.pickup_screen_title)) - }, - navigationIcon = { - NavigationClose(onClick = { mainNavController.popBackStack() }) - }, - elevation = 0.dp - ) - } - ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer8() - Text( - text = stringResource(id = R.string.pickup_screen_info), - style = MaterialTheme.typography.subtitle2 - ) - Spacer32() - pickupCodeHR?.let { - androidx.compose.material.Surface( - modifier = Modifier.fillMaxWidth(), - color = AppTheme.colors.neutral100, - shape = RoundedCornerShape(8.dp) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(16.dp) - ) { - Text(text = pickupCodeHR, style = MaterialTheme.typography.h5) - Spacer4() - Text( - text = stringResource(id = R.string.pickup_screen_title), - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral600 - ) - } - } - Spacer8() - } - pickupCodeDMC?.let { - val code = remember { viewModel.createBitmapMatrix(it) } - - Spacer8() - DataMatrixCode( - code, - modifier = Modifier - .aspectRatio(1.0f) - ) - Spacer4() - Text(text = it, color = AppTheme.colors.neutral600) - } - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/MessageComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/messages/ui/MessageComponents.kt deleted file mode 100644 index 27d32dbc..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/MessageComponents.kt +++ /dev/null @@ -1,279 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.OpenInBrowser -import androidx.compose.material.icons.filled.QrCode -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import de.gematik.ti.erp.app.BuildConfig -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens -import de.gematik.ti.erp.app.messages.ui.models.CommunicationReply -import de.gematik.ti.erp.app.messages.ui.models.ErrorUIMessage -import de.gematik.ti.erp.app.messages.ui.models.UIMessage -import de.gematik.ti.erp.app.messages.usecase.ERROR -import de.gematik.ti.erp.app.messages.usecase.LOCAL -import de.gematik.ti.erp.app.messages.usecase.SHIPMENT -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer8 -import de.gematik.ti.erp.app.utils.compose.SpacerMedium -import de.gematik.ti.erp.app.utils.compose.canHandleIntent -import de.gematik.ti.erp.app.utils.compose.testId -import kotlinx.coroutines.flow.collect -import java.lang.StringBuilder - -@ExperimentalMaterialApi -@Composable -fun MessageScreen(mainNavController: NavController, viewModel: MessageViewModel) { - val result by produceState(initialValue = listOf()) { - viewModel.fetchCommunications().collect { value = it } - } - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - if (result.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize().testTag("message_screen"), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - stringResource(id = R.string.messages_empty_screen), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.testTag("emptyMessagesHeader") - ) - Spacer16() - Text( - stringResource(id = R.string.messages_empty_screen_info), - style = AppTheme.typography.body2l, - modifier = Modifier.testId("msgs_txt_empty_list") - ) - } - } else { - LazyColumn( - Modifier - .padding(start = PaddingDefaults.Tiny, end = PaddingDefaults.Medium) - .testTag("lazyColumn") - ) { - item { - SpacerMedium() - } - items(items = result) { message -> - when (message) { - is UIMessage -> { - Message( - message, - { viewModel.messageAcknowledged(message.copy(consumed = true)) } - ) { - when (message.supplyOptionsType) { - LOCAL -> { - mainNavController.navigate( - MainNavigationScreens.PickUpCode.path( - pickUpCodeHR = message.pickUpCodeHR, - pickUpCodeDMC = message.pickUpCodeDMC - ) - ) - } - SHIPMENT -> { - message.url?.let { url -> - uriHandler.openUri(url) - } - } - } - } - } - is ErrorUIMessage -> { - val mailTo = stringResource(id = R.string.messages_contact_mail_address) - val subject = stringResource(id = R.string.messages_contact_email_subject) - val body = stringResource(id = R.string.messages_contact_email_body) - val errorCode = - stringResource(id = R.string.messages_contact_email_error_code) - val dataInfo = - stringResource(id = R.string.messages_contact_email_data_transparency) - val emailBody = - generateBody( - body, - dataInfo, - errorCode, - message.message ?: "", - message.timeStamp - ) - Message( - message = message, - onRowClick = { viewModel.messageAcknowledged(message.copy(consumed = true)) } - ) { - email(mailTo, subject, emailBody, context) - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun Message( - message: CommunicationReply, - onRowClick: () -> Unit, - onActionClick: () -> Unit, -) { - val icon = when (message.supplyOptionsType) { - SHIPMENT -> Icons.Filled.OpenInBrowser - ERROR -> Icons.Default.KeyboardArrowRight - else -> Icons.Default.QrCode - } - val color = if (message.consumed) Color.Transparent else AppTheme.colors.primary500 - val infoText = - if (message is UIMessage) message.message else stringResource(id = (message as ErrorUIMessage).displayText) - Row( - modifier = Modifier.clickable { - onRowClick() - } - ) { - NewMessageDot(color) - Spacer8() - Column { - Text(stringResource(id = message.header), style = MaterialTheme.typography.subtitle1) - Spacer8() - Text( - infoText - ?: stringResource(id = R.string.communication_info_text_not_available), - style = MaterialTheme.typography.body1 - ) - Spacer8() - if (message.actionText != -1) { - Spacer8() - Row { - Text( - modifier = Modifier.clickable { - onRowClick() - onActionClick() - }, - text = stringResource(id = message.actionText), - color = AppTheme.colors.primary600 - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - imageVector = icon, - tint = AppTheme.colors.primary600, - contentDescription = "" - ) - } - Spacer8() - } - Divider( - modifier = Modifier.padding( - top = PaddingDefaults.Medium, - bottom = PaddingDefaults.Medium - ) - ) - } - } -} - -@Composable -fun NewMessageDot(color: Color) { - Canvas( - modifier = Modifier - .padding(start = PaddingDefaults.Tiny, top = 5.dp) - .size(12.dp), - onDraw = { - drawCircle(color = color) - } - ) -} - -fun email(address: String, subject: String, message: String, context: Context) { - val intent = emailIntent(address, subject, message) - if (canHandleIntent(intent, context.packageManager)) { - context.startActivity(intent) - } -} - -private fun emailIntent(address: String, subject: String, body: String) = Intent().apply { - data = (Uri.parse("mailto:")) - action = Intent.ACTION_SENDTO - putExtra(Intent.EXTRA_EMAIL, arrayOf(address)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, body) -} - -private fun generateBody( - firstPart: String, - secondPart: String, - errorCode: String, - message: String, - time: String -): String { - val body = StringBuilder() - .append(firstPart) - .append("\n\n") - .append(secondPart) - .append("\n\n") - .append("----------------") - .append("\n\n") - .append(errorCode) - .append("\n\n") - .append(message) - .append("\n\n") - .append("App Version Code: ${BuildConfig.VERSION_CODE}") - .append("\n\n") - .append("OS Version: ${System.getProperty("os.version")}") - .append("\n\n") - .append("Device Info: ${Build.MODEL}") - .append("\n\n") - .append("Server Timestamp: $time") - - return body.toString() -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/MessageViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/messages/ui/MessageViewModel.kt deleted file mode 100644 index e654b5b6..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/MessageViewModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui - -import androidx.lifecycle.viewModelScope -import com.google.zxing.BarcodeFormat -import com.google.zxing.datamatrix.DataMatrixWriter -import dagger.hilt.android.lifecycle.HiltViewModel -import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.messages.ui.models.CommunicationReply -import de.gematik.ti.erp.app.messages.usecase.MessageUseCase -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import javax.inject.Inject -import kotlinx.coroutines.launch - -@HiltViewModel -class MessageViewModel @Inject constructor( - private val useCase: MessageUseCase, - private val dispatchProvider: DispatchProvider -) : BaseViewModel() { - fun fetchCommunications() = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - - fun createBitmapMatrix(payload: String) = - BitMatrixCode(DataMatrixWriter().encode(payload, BarcodeFormat.DATA_MATRIX, 1, 1)) - - fun messageAcknowledged(message: CommunicationReply) { - viewModelScope.launch(dispatchProvider.main()) { - useCase.updateCommunicationResource(message.communicationId, message.consumed) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/models/UIMessage.kt b/android/src/main/java/de/gematik/ti/erp/app/messages/ui/models/UIMessage.kt deleted file mode 100644 index ce08cc80..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/messages/ui/models/UIMessage.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui.models - -sealed class CommunicationReply( - open val communicationId: String, - open val supplyOptionsType: String, - open val header: Int, - open val message: String?, - open val actionText: Int = -1, - open val consumed: Boolean -) - -data class UIMessage( - override val communicationId: String, - override val supplyOptionsType: String, - override val header: Int, - override val message: String?, - val pickUpCodeHR: String? = null, - val pickUpCodeDMC: String? = null, - val url: String? = null, - override val actionText: Int = -1, - override val consumed: Boolean -) : CommunicationReply( - communicationId, supplyOptionsType, header, message, actionText, consumed -) - -data class ErrorUIMessage( - override val communicationId: String, - override val supplyOptionsType: String, - override val header: Int, - override val message: String?, - val displayText: Int, - val timeStamp: String, - override val actionText: Int = -1, - override val consumed: Boolean -) : CommunicationReply( - communicationId, supplyOptionsType, header, message, actionText, consumed -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/messages/usecase/MessageUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/messages/usecase/MessageUseCase.kt deleted file mode 100644 index 87f4d7bc..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/messages/usecase/MessageUseCase.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.usecase - -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.messages.repository.MessageRepository -import de.gematik.ti.erp.app.messages.ui.models.CommunicationReply -import de.gematik.ti.erp.app.messages.ui.models.ErrorUIMessage -import de.gematik.ti.erp.app.messages.ui.models.UIMessage -import de.gematik.ti.erp.app.pharmacy.repository.model.CommunicationPayloadInbox -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import kotlinx.coroutines.ExperimentalCoroutinesApi -import javax.inject.Inject -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map - -const val SHIPMENT = "shipment" -const val LOCAL = "onPremise" -const val DELIVERY = "delivery" -const val ERROR = "none" - -@OptIn(ExperimentalCoroutinesApi::class) -class MessageUseCase @Inject constructor( - private val repository: MessageRepository, - private val profilesUseCase: ProfilesUseCase, - private val moshi: Moshi -) { - - private val adapter by lazy { - moshi.adapter(CommunicationPayloadInbox::class.java) - } - - fun loadCommunicationsLocally(profile: CommunicationProfile) = - profilesUseCase.activeProfileName().flatMapLatest { activeProfileName -> - repository.loadCommunications(profile, activeProfileName) - .map { - it.map { communication -> - mapToUIMessage(communication) - } - } - } - - fun unreadCommunicationsAvailable(profile: CommunicationProfile) = - profilesUseCase.activeProfileName().flatMapLatest { activeProfileName -> - repository.loadUnreadCommunications(profile, activeProfileName).map { it.isNotEmpty() } - } - - suspend fun updateCommunicationResource(communicationId: String, consumed: Boolean) { - repository.setCommunicationAcknowledgedStatus(communicationId, consumed) - } - - private fun mapToUIMessage(communication: Communication): CommunicationReply { - communication.payload?.let { contentString -> - val payload: CommunicationPayloadInbox? - try { - payload = adapter.fromJson(contentString) - } catch (e: Exception) { - return errorMessage( - contentString, - communication.communicationId, - communication.consumed, - communication.time - ) - } - return when (payload?.supplyOptionsType) { - SHIPMENT -> - shipmentMessage( - payload, - communication.communicationId, - communication.consumed - ) - LOCAL -> - localMessage( - payload, - communication.communicationId, - communication.consumed - ) - DELIVERY -> - deliveryMessage( - payload, - communication.communicationId, - communication.consumed - ) - else -> - errorMessage( - contentString, - communication.communicationId, - communication.consumed, - communication.time - ) - } - } - return errorMessage( - "empty content string", - communication.communicationId, - communication.consumed, - communication.time - ) - } - - private fun shipmentMessage( - payload: CommunicationPayloadInbox, - communicationId: String, - consumed: Boolean - ) = - UIMessage( - communicationId = communicationId, - supplyOptionsType = payload.supplyOptionsType, - header = R.string.communication_shipment_inbox_header, - message = if (payload.infoText.isEmpty()) null else payload.infoText, - url = payload.url, - actionText = if (payload.url.isNullOrEmpty()) -1 else R.string.communication_shipment_action_text, - consumed = consumed - ) - - private fun localMessage( - payload: CommunicationPayloadInbox, - communicationId: String, - consumed: Boolean - ) = - UIMessage( - communicationId = communicationId, - supplyOptionsType = payload.supplyOptionsType, - header = if (payload.pickUpCodeHR.isNullOrEmpty()) R.string.communication_local_inbox_header_no_dmc else R.string.communication_local_inbox_header_dmc, - message = if (payload.infoText.isEmpty()) null else payload.infoText, - pickUpCodeHR = payload.pickUpCodeHR, - pickUpCodeDMC = payload.pickUpCodeDMC, - actionText = if (payload.pickUpCodeHR.isNullOrEmpty()) -1 else R.string.communication_local_action_text, - consumed = consumed - ) - - private fun deliveryMessage( - payload: CommunicationPayloadInbox, - communicationId: String, - consumed: Boolean - ) = - UIMessage( - communicationId = communicationId, - supplyOptionsType = payload.supplyOptionsType, - header = R.string.communication_delivery_inbox_header, - message = if (payload.infoText.isEmpty()) null else payload.infoText, - consumed = consumed - ) - - private fun errorMessage( - message: String, - communicationId: String, - consumed: Boolean, - timestamp: String - ) = - ErrorUIMessage( - communicationId = communicationId, - supplyOptionsType = ERROR, - header = R.string.communication_error_inbox_header, - message = message, - displayText = R.string.communication_error_inbox_display_text, - actionText = R.string.communication_error_action_text, - consumed = consumed, - timeStamp = timestamp - ) -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingAppAuthentication.kt b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingAppAuthentication.kt new file mode 100644 index 00000000..4750e582 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingAppAuthentication.kt @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.onboarding.ui + +import android.os.Parcelable +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.with +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.mainscreen.ui.TextTabRow +import de.gematik.ti.erp.app.pharmacy.ui.scrollOnFocus +import de.gematik.ti.erp.app.settings.ui.ConfirmationPasswordTextField +import de.gematik.ti.erp.app.settings.ui.PasswordStrength +import de.gematik.ti.erp.app.settings.ui.PasswordTextField +import de.gematik.ti.erp.app.settings.ui.checkPassword +import de.gematik.ti.erp.app.settings.ui.checkPasswordScore +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerTiny +import de.gematik.ti.erp.app.utils.compose.visualTestTag +import kotlinx.parcelize.Parcelize + +private const val POS_OF_ANIMATED_CONTENT_ITEM = 3 + +@Immutable +sealed class OnboardingSecureAppMethod { + @Immutable + @Parcelize + data class Password(val password: String, val repeatedPassword: String, val score: Int) : + OnboardingSecureAppMethod(), + Parcelable { + val checkedPassword: String? + get() = + if (checkPassword(password, repeatedPassword, score)) { + password + } else { + null + } + } + + @Parcelize + object DeviceSecurity : OnboardingSecureAppMethod(), Parcelable + + @Parcelize + object None : OnboardingSecureAppMethod(), Parcelable +} + +@Immutable +private enum class AuthTab(val index: Int) { + Password(1), Biometric(0) +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun OnboardingSecureApp( + secureMethod: OnboardingSecureAppMethod, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit, + onNextPage: () -> Unit, + onOpenBiometricScreen: () -> Unit +) { + val lazyListState = rememberLazyListState() + var selectedTab by remember { mutableStateOf(AuthTab.Biometric) } + + OnboardingScaffold( + modifier = Modifier + .testTag(TestTag.Onboarding.CredentialsScreen) + .fillMaxSize(), + state = lazyListState, + bottomBar = { + OnboardingBottomBar( + info = when (selectedTab) { + AuthTab.Password -> null + AuthTab.Biometric -> stringResource(R.string.onboarding_auth_biometric_info) + }, + buttonText = when (selectedTab) { + AuthTab.Password -> stringResource(R.string.onboarding_bottom_button_save) + AuthTab.Biometric -> stringResource(R.string.onboarding_bottom_button_choose) + }, + buttonEnabled = when (selectedTab) { + AuthTab.Password -> (secureMethod as? OnboardingSecureAppMethod.Password)?.checkedPassword != null + AuthTab.Biometric -> true + }, + buttonModifier = Modifier.testTag(TestTag.Onboarding.NextButton), + onButtonClick = { + when (selectedTab) { + AuthTab.Password -> + onNextPage() + AuthTab.Biometric -> + onOpenBiometricScreen() + } + } + ) + } + ) { + item { + Image( + painterResource(R.drawable.developer), + contentDescription = null, + alignment = Alignment.CenterStart, + modifier = Modifier + .padding( + top = PaddingDefaults.XXLarge + ) + .fillMaxWidth() + ) + } + item { + Text( + text = stringResource(R.string.on_boarding_secure_app_page_header), + style = AppTheme.typography.h4, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Start, + modifier = Modifier.padding( + bottom = PaddingDefaults.XLarge, + top = PaddingDefaults.XXLarge + ) + ) + } + item { + TextTabRow( + selectedTabIndex = selectedTab.index, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = PaddingDefaults.XXLarge), + backGroundColor = MaterialTheme.colors.background, + onClick = { + when (it) { + 0 -> { + selectedTab = AuthTab.Biometric + } + 1 -> { + selectedTab = AuthTab.Password + } + } + onSecureMethodChange(OnboardingSecureAppMethod.None) + }, + tabs = listOf( + stringResource(R.string.onboarding_secure_app_biometric), + stringResource(R.string.onboarding_secure_app_password) + ), + testTags = listOf( + TestTag.Onboarding.Credentials.BiometricTab, + TestTag.Onboarding.Credentials.PasswordTab + ) + ) + } + item { + AnimatedContent( + targetState = selectedTab, + transitionSpec = { + if (targetState.index > initialState.index) { + slideInHorizontally { width -> width } + fadeIn() with + slideOutHorizontally { width -> -width } + fadeOut() + } else { + slideInHorizontally { height -> -height } + fadeIn() with + slideOutHorizontally { width -> width } + fadeOut() + }.using( + SizeTransform(clip = false) + ) + } + ) { targetTab -> + when (targetTab) { + AuthTab.Password -> { + PasswordAuthentication( + secureMethod = secureMethod, + lazyListState = lazyListState, + onSecureMethodChange = onSecureMethodChange, + onNext = onNextPage + ) + } + AuthTab.Biometric -> { + } + } + } + SpacerMedium() + } + } +} + +@Composable +private fun PasswordAuthentication( + secureMethod: OnboardingSecureAppMethod, + lazyListState: LazyListState, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit, + onNext: () -> Unit +) { + var offsetFirstPassword by remember { mutableStateOf(0) } + var offsetSecondPassword by remember { mutableStateOf(0) } + + val password = + remember(secureMethod) { (secureMethod as? OnboardingSecureAppMethod.Password)?.password ?: "" } + val repeatedPassword = + remember(secureMethod) { + (secureMethod as? OnboardingSecureAppMethod.Password)?.repeatedPassword ?: "" + } + val passwordScore = + remember(secureMethod) { + (secureMethod as? OnboardingSecureAppMethod.Password)?.score ?: 0 + } + + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier.wrapContentSize() + ) { + PasswordTextField( + modifier = Modifier + .visualTestTag(TestTag.Onboarding.Credentials.PasswordFieldA) + .fillMaxWidth() + .scrollOnFocus(POS_OF_ANIMATED_CONTENT_ITEM, lazyListState, offsetFirstPassword) + .onGloballyPositioned { offsetFirstPassword = it.positionInParent().y.toInt() } + .padding(bottom = PaddingDefaults.Tiny), + value = password, + onValueChange = { + if (it.isEmpty()) { + onSecureMethodChange(OnboardingSecureAppMethod.None) + } else { + onSecureMethodChange( + OnboardingSecureAppMethod.Password( + password = it, + repeatedPassword = repeatedPassword, + score = passwordScore + ) + ) + } + }, + onSubmit = { + if (checkPasswordScore(passwordScore)) { + focusManager.moveFocus(FocusDirection.Down) + } + }, + allowAutofill = true, + allowVisiblePassword = true, + label = { + Text(stringResource(R.string.settings_password_enter)) + } + ) + PasswordStrength( + modifier = Modifier + .testTag(TestTag.Onboarding.Credentials.PasswordStrengthCheck) + .fillMaxWidth() + .padding(bottom = PaddingDefaults.Medium), + password = password, + onScoreChange = { + onSecureMethodChange( + OnboardingSecureAppMethod.Password( + password = password, + repeatedPassword = repeatedPassword, + score = it + ) + ) + } + ) + ConfirmationPasswordTextField( + modifier = Modifier + .visualTestTag(TestTag.Onboarding.Credentials.PasswordFieldB) + .fillMaxWidth() + .scrollOnFocus(POS_OF_ANIMATED_CONTENT_ITEM, lazyListState, offsetSecondPassword) + .onGloballyPositioned { offsetSecondPassword = it.positionInParent().y.toInt() }, + password = password, + value = repeatedPassword, + passwordScore = passwordScore, + onValueChange = { + onSecureMethodChange( + OnboardingSecureAppMethod.Password( + password = password, + repeatedPassword = it, + score = passwordScore + ) + ) + }, + onSubmit = { + focusManager.clearFocus() + onNext() + } + ) + if (repeatedPassword.isNotBlank() && repeatedPassword != password) { + SpacerTiny() + Text( + stringResource(R.string.not_matching_entries), + style = AppTheme.typography.caption1, + color = AppTheme.colors.red600.copy( + alpha = ContentAlpha.high + ) + ) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingComponents.kt index 3cd00fae..0a296af1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingComponents.kt @@ -18,63 +18,41 @@ package de.gematik.ti.erp.app.onboarding.ui -import android.os.Parcelable import androidx.activity.compose.BackHandler -import androidx.annotation.FloatRange -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.with import androidx.compose.foundation.Image import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Switch import androidx.compose.material.Text -import androidx.compose.material.contentColorFor import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowForward -import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.LiveHelp -import androidx.compose.material.icons.rounded.RadioButtonUnchecked -import androidx.compose.material.icons.rounded.Timeline -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -83,116 +61,99 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.focused import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.google.accompanist.insets.statusBarsPadding -import com.google.accompanist.pager.ExperimentalPagerApi -import com.google.accompanist.pager.HorizontalPager -import com.google.accompanist.pager.PagerDefaults -import com.google.accompanist.pager.PagerState -import com.google.accompanist.pager.rememberPagerState +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.icons.rounded.FlashOn +import androidx.compose.material.icons.rounded.PersonPin +import androidx.compose.material.icons.rounded.Star import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.Route -import de.gematik.ti.erp.app.core.LocalActivity -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod +import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens +import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.settings.ui.AllowAnalyticsScreen -import de.gematik.ti.erp.app.settings.ui.ConfirmationPasswordTextField -import de.gematik.ti.erp.app.settings.ui.PasswordStrength -import de.gematik.ti.erp.app.settings.ui.PasswordTextField +import de.gematik.ti.erp.app.settings.ui.AllowBiometryScreen import de.gematik.ti.erp.app.settings.ui.SettingsViewModel -import de.gematik.ti.erp.app.settings.ui.checkPassword -import de.gematik.ti.erp.app.settings.usecase.DEFAULT_PROFILE_NAME -import de.gematik.ti.erp.app.webview.URI_DATA_TERMS -import de.gematik.ti.erp.app.webview.URI_TERMS_OF_USE -import de.gematik.ti.erp.app.webview.WebViewScreen import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.userauthentication.ui.BiometricPrompt -import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog -import de.gematik.ti.erp.app.utils.compose.LargeButton +import de.gematik.ti.erp.app.utils.compose.BottomAppBar import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.OutlinedDebugButton -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer24 -import de.gematik.ti.erp.app.utils.compose.Spacer4 -import de.gematik.ti.erp.app.utils.compose.Spacer40 +import de.gematik.ti.erp.app.utils.compose.SecondaryButton import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.SpacerTiny -import de.gematik.ti.erp.app.utils.compose.annotatedStringBold -import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge import de.gematik.ti.erp.app.utils.compose.createToastShort -import de.gematik.ti.erp.app.utils.compose.minimalSystemBarsPadding import de.gematik.ti.erp.app.utils.compose.navigationModeState -import de.gematik.ti.erp.app.utils.compose.testId -import dev.chrisbanes.snapper.ExperimentalSnapperApi +import de.gematik.ti.erp.app.utils.compose.visualTestTag +import de.gematik.ti.erp.app.webview.URI_DATA_TERMS +import de.gematik.ti.erp.app.webview.URI_TERMS_OF_USE +import de.gematik.ti.erp.app.webview.WebViewScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize -import java.time.LocalDate import java.util.Locale import kotlin.math.max -import kotlin.math.roundToInt +import kotlin.math.min object OnboardingNavigationScreens { object Onboarding : Route("Onboarding") object Analytics : Route("Analytics") object TermsOfUse : Route("TermsOfUse") object DataProtection : Route("DataProtection") + object Biometry : Route("Biometry") } -private const val MAX_PAGES = 6 -private const val WELCOME_PAGE = 0 -private const val FEATURE_PAGE = 1 +private enum class OnboardingPages(val index: Int) { + Welcome(index = 0), + DataProtection(index = 1), + SecureApp(index = 2), + Analytics(index = 3); + + companion object { + val MaxPage = OnboardingPages.values().size - 1 -private const val PROFILE_PAGE = 2 -private const val SECURE_APP_PAGE = 3 -private const val ANALYTICS_PAGE = 4 -private const val TOS_AND_DATA_PAGE = 5 + fun pageOf(index: Int) = + OnboardingPages.values().find { + it.index == min(MaxPage, max(0, index)) + }!! + } +} @Composable fun ReturningUserSecureAppOnboardingScreen( mainNavController: NavController, - settingsViewModel: SettingsViewModel = hiltViewModel() + settingsViewModel: SettingsViewModel, + secureMethod: OnboardingSecureAppMethod, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit ) { - var secureMethod by rememberSaveable { mutableStateOf(SecureAppMethod.None) } - val enabled = when { - secureMethod is SecureAppMethod.DeviceSecurity -> true - secureMethod is SecureAppMethod.Password -> (secureMethod as? SecureAppMethod.Password)?.let { + val enabled = when (secureMethod) { + is OnboardingSecureAppMethod.DeviceSecurity -> true + is OnboardingSecureAppMethod.Password -> (secureMethod as? OnboardingSecureAppMethod.Password)?.let { it.checkedPassword != null } ?: false + else -> false } + val coroutineScope = rememberCoroutineScope() Scaffold( modifier = Modifier.statusBarsPadding(), bottomBar = { @@ -201,19 +162,23 @@ fun ReturningUserSecureAppOnboardingScreen( Button( enabled = enabled, onClick = { - when (val sm = secureMethod) { - is SecureAppMethod.DeviceSecurity -> - settingsViewModel.onSelectDeviceSecurityAuthenticationMode() - is SecureAppMethod.Password -> - settingsViewModel.onSelectPasswordAsAuthenticationMode( - requireNotNull(sm.checkedPassword) - ) - else -> error("Illegal state. Authentication must be set") - } - mainNavController.navigate(MainNavigationScreens.Prescriptions.path()) { - launchSingleTop = true - popUpTo(MainNavigationScreens.ReturningUserSecureAppOnboarding.path()) { - inclusive = true + coroutineScope.launch { + when (val sm = secureMethod) { + is OnboardingSecureAppMethod.DeviceSecurity -> + settingsViewModel.onSelectDeviceSecurityAuthenticationMode() + + is OnboardingSecureAppMethod.Password -> + settingsViewModel.onSelectPasswordAsAuthenticationMode( + requireNotNull(sm.checkedPassword) + ) + + else -> error("Illegal state. Authentication must be set") + } + mainNavController.navigate(MainNavigationScreens.Prescriptions.path()) { + launchSingleTop = true + popUpTo(MainNavigationScreens.ReturningUserSecureAppOnboarding.path()) { + inclusive = true + } } } }, @@ -225,29 +190,30 @@ fun ReturningUserSecureAppOnboardingScreen( } } ) { innerPadding -> - OnboardingSecureApp( - Modifier.padding(innerPadding), - secureMethod = secureMethod, - isReturningUser = true, - onSecureMethodChange = { - secureMethod = it - } - ) + Box(Modifier.padding(innerPadding)) { + OnboardingSecureApp( + secureMethod = secureMethod, + onSecureMethodChange = onSecureMethodChange, + onNextPage = {}, + onOpenBiometricScreen = { mainNavController.navigate(MainNavigationScreens.Biometry.path()) } + ) + } } } @Composable fun OnboardingScreen( mainNavController: NavController, - settingsViewModel: SettingsViewModel = hiltViewModel( - LocalActivity.current - ) + settingsViewModel: SettingsViewModel ) { val navController = rememberNavController() + val coroutineScope = rememberCoroutineScope() - var allowTracking by rememberSaveable { mutableStateOf(false) } + var allowAnalytics by rememberSaveable { mutableStateOf(false) } + var secureMethod by rememberSaveable { mutableStateOf(OnboardingSecureAppMethod.None) } val navigationMode by navController.navigationModeState(OnboardingNavigationScreens.Onboarding.route) + NavHost( navController, startDestination = OnboardingNavigationScreens.Onboarding.route @@ -256,34 +222,37 @@ fun OnboardingScreen( NavigationAnimation(mode = navigationMode) { OnboardingScreenWithScaffold( navController, - allowTracking = allowTracking, + secureMethod = secureMethod, + onSecureMethodChange = { + secureMethod = it + }, + allowTracking = allowAnalytics, onAllowTracking = { - allowTracking = it + allowAnalytics = it }, - onSaveNewUser = { allowTracking, secureMethod, profileName -> - when (secureMethod) { - is SecureAppMethod.DeviceSecurity -> - settingsViewModel.onSelectDeviceSecurityAuthenticationMode() - is SecureAppMethod.Password -> - settingsViewModel.onSelectPasswordAsAuthenticationMode( - requireNotNull(secureMethod.checkedPassword) - ) - else -> error("Illegal state. Authentication must be set") - } - - settingsViewModel.isNewUser = false - settingsViewModel.overwriteDefaultProfile(profileName) - settingsViewModel.acceptUpdatedDataTerms(LocalDate.now()) + onSaveNewUser = { allowTracking, defaultProfileName, secureMethod -> + coroutineScope.launch(Dispatchers.Main) { + settingsViewModel.onboardingSucceeded( + authenticationMode = when (secureMethod) { + is OnboardingSecureAppMethod.DeviceSecurity -> + SettingsData.AuthenticationMode.DeviceSecurity + + is OnboardingSecureAppMethod.Password -> + SettingsData.AuthenticationMode.Password( + password = requireNotNull(secureMethod.checkedPassword) + ) + + else -> error("Illegal state. Authentication must be set") + }, + defaultProfileName = defaultProfileName, + allowTracking = allowTracking + ) - if (allowTracking) { - settingsViewModel.onTrackingAllowed() - } else { - settingsViewModel.onTrackingDisallowed() - } - mainNavController.navigate(MainNavigationScreens.Prescriptions.path()) { - launchSingleTop = true - popUpTo(MainNavigationScreens.Onboarding.path()) { - inclusive = true + mainNavController.navigate(MainNavigationScreens.Prescriptions.path()) { + launchSingleTop = true + popUpTo(MainNavigationScreens.Onboarding.path()) { + inclusive = true + } } } } @@ -292,15 +261,25 @@ fun OnboardingScreen( } composable(OnboardingNavigationScreens.Analytics.route) { NavigationAnimation(mode = navigationMode) { - AllowAnalyticsScreen { - allowTracking = it - navController.popBackStack() - } + AllowAnalyticsScreen( + onBack = { navController.popBackStack() }, + onAllowAnalytics = { allowAnalytics = it } + ) + } + } + composable(OnboardingNavigationScreens.Biometry.route) { + NavigationAnimation(mode = navigationMode) { + AllowBiometryScreen( + onBack = { navController.popBackStack() }, + onNext = { navController.popBackStack() }, + onSecureMethodChange = { secureMethod = it } + ) } } composable(OnboardingNavigationScreens.TermsOfUse.route) { NavigationAnimation(mode = navigationMode) { WebViewScreen( + modifier = Modifier.testTag(TestTag.Onboarding.TermsOfUseScreen), title = stringResource(R.string.onb_terms_of_use), onBack = { navController.popBackStack() }, url = URI_TERMS_OF_USE @@ -310,6 +289,7 @@ fun OnboardingScreen( composable(OnboardingNavigationScreens.DataProtection.route) { NavigationAnimation(mode = navigationMode) { WebViewScreen( + modifier = Modifier.testTag(TestTag.Onboarding.DataProtectionScreen), title = stringResource(R.string.onb_data_consent), onBack = { navController.popBackStack() }, url = URI_DATA_TERMS @@ -319,593 +299,360 @@ fun OnboardingScreen( } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class, ExperimentalSnapperApi::class) +@Suppress("LongMethod") +@OptIn(ExperimentalAnimationApi::class) @Composable private fun OnboardingScreenWithScaffold( navController: NavController, + secureMethod: OnboardingSecureAppMethod, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit, allowTracking: Boolean, onAllowTracking: (Boolean) -> Unit, - onSaveNewUser: (allowTracking: Boolean, secureAppMethod: SecureAppMethod, profileName: String) -> Unit + onSaveNewUser: ( + allowTracking: Boolean, + defaultProfileName: String, + secureAppMethod: OnboardingSecureAppMethod + ) -> Unit ) { val context = LocalContext.current - var tosAndDataToggled by remember { mutableStateOf(false) } - var secureMethod by rememberSaveable { mutableStateOf(SecureAppMethod.None) } - var profileName by rememberSaveable { mutableStateOf("") } + val defaultProfileName = stringResource(R.string.onboarding_default_profile_name) - val state = rememberPagerState(initialPage = 0) + Box { + var page by rememberSaveable { mutableStateOf(OnboardingPages.Welcome) } - val maxPages = if (profileName.isBlank()) { - PROFILE_PAGE + 1 - } else - when (secureMethod) { - is SecureAppMethod.Password -> (secureMethod as? SecureAppMethod.Password)?.let { - if (it.checkedPassword != null) MAX_PAGES else SECURE_APP_PAGE + 1 - } ?: (SECURE_APP_PAGE + 1) - is SecureAppMethod.DeviceSecurity -> MAX_PAGES - else -> SECURE_APP_PAGE + 1 + LaunchedEffect(secureMethod) { + if (secureMethod is OnboardingSecureAppMethod.DeviceSecurity && page == OnboardingPages.SecureApp) { + page = OnboardingPages.Analytics + } } - val scope = rememberCoroutineScope() - BackHandler(enabled = state.currentPage > 0) { - scope.launch { - state.animateScrollToPage(max(0, state.currentPage - 1)) + BackHandler(enabled = page.index > 1) { + page = OnboardingPages.pageOf(page.index - 1) } - } - Scaffold( - modifier = Modifier - .testTag("screen_onboarding") - .minimalSystemBarsPadding() - ) { - Box { - HorizontalPager( - count = maxPages, - modifier = Modifier - .fillMaxSize(), - state = state, - flingBehavior = PagerDefaults.flingBehavior( - state = state, - snapAnimationSpec = SpringSpec() - ), - key = { - it - } - ) { page -> - when (page) { - WELCOME_PAGE -> { - OnboardingWelcome( - Modifier.semantics { focused = state.currentPage == WELCOME_PAGE }, - state - ) - } - FEATURE_PAGE -> { - OnboardingAppFeatures( - Modifier.semantics { - focused = state.currentPage == FEATURE_PAGE - } - ) - } - PROFILE_PAGE -> { - OnboardingProfile( - modifier = Modifier.semantics { - focused = state.currentPage == PROFILE_PAGE - }, - profileName = profileName, - onProfileNameChange = { profileName = it }, - onNext = {} - ) - } - SECURE_APP_PAGE -> { - OnboardingSecureApp( - Modifier.semantics { focused = state.currentPage == SECURE_APP_PAGE }, - secureMethod = secureMethod, - onSecureMethodChange = { - secureMethod = it - } - ) - } - ANALYTICS_PAGE -> { - val disAllowToast = stringResource(R.string.settings_tracking_disallow_info) - OnboardingPageAnalytics( - Modifier.semantics { focused = state.currentPage == ANALYTICS_PAGE }, - allowTracking = allowTracking, - onAllowTracking = { - if (!it) { - onAllowTracking(false) - createToastShort(context, disAllowToast) - } else { - navController.navigate(OnboardingNavigationScreens.Analytics.path()) - } - } - ) + AnimatedContent( + modifier = Modifier.fillMaxSize(), + targetState = page, + transitionSpec = { + when { + initialState == OnboardingPages.Welcome && + targetState == OnboardingPages.pageOf(1) -> { + fadeIn(tween(durationMillis = 770)) with fadeOut(tween(durationMillis = 770)) } - TOS_AND_DATA_PAGE -> { - OnboardingPageTerms( - Modifier.semantics { focused = state.currentPage == TOS_AND_DATA_PAGE }, - navController, - ) { - tosAndDataToggled = it - } - } - } - } - BottomPageIndicator(state) + initialState.index > targetState.index -> { + slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with + slideOutOfContainer(AnimatedContentScope.SlideDirection.Right) + } - OnboardingNextButton( - Modifier.testId("onb_btn_next"), - profileName, - secureMethod, - tosAndDataToggled, - currentPage = state.currentPage, - onNextPage = { - scope.launch { - state.animateScrollToPage(state.currentPage + 1) + else -> { + slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with + slideOutOfContainer(AnimatedContentScope.SlideDirection.Left) } - }, - onSaveNewUser = { - onSaveNewUser(allowTracking, secureMethod, profileName) } - ) - - if (BuildKonfig.INTERNAL) { - OutlinedDebugButton( - "SKIP", - onClick = { - onSaveNewUser(false, SecureAppMethod.Password("a", "a", 9), DEFAULT_PROFILE_NAME) - }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(PaddingDefaults.Medium) - ) } - } - } -} - -@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class) -@Composable -private fun BoxScope.BottomPageIndicator(pagerState: PagerState) { - Box( - modifier = Modifier - .padding(bottom = 24.dp) - .clip(CircleShape) - .background(AppTheme.colors.neutral100.copy(alpha = 0.5f)) - .align(Alignment.BottomCenter) - .padding(PaddingDefaults.Small) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) ) { - repeat(MAX_PAGES) { - Dot(color = AppTheme.colors.neutral300) - } - } - - val fraction = pagerState.currentPage + pagerState.currentPageOffset - val offsetX = with(LocalDensity.current) { - val gap = PaddingDefaults.Small.roundToPx() - val size = 8.dp.roundToPx() - (fraction * (size + gap)).toDp() - } - Dot(modifier = Modifier.offset(x = offsetX), color = AppTheme.colors.primary500) - } -} - -@Composable -private fun Dot(modifier: Modifier = Modifier, color: Color) { - Box( - modifier = modifier - .clip(CircleShape) - .background(color) - .size(8.dp) - ) -} + when (it) { + OnboardingPages.Welcome -> { + OnboardingWelcome( + onNextPage = { + page = OnboardingPages.DataProtection + } + ) + } -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) -@Composable -private fun BoxScope.OnboardingNextButton( - modifier: Modifier = Modifier, - profileName: String, - secureMethod: SecureAppMethod, - tosAndDataToggled: Boolean, - currentPage: Int, - onNextPage: () -> Unit, - onSaveNewUser: () -> Unit -) { - val enabled = when { - currentPage == WELCOME_PAGE || currentPage == FEATURE_PAGE || currentPage == ANALYTICS_PAGE -> true - currentPage == PROFILE_PAGE && profileName.isNotEmpty() -> true - currentPage == SECURE_APP_PAGE && secureMethod is SecureAppMethod.DeviceSecurity -> true - currentPage == SECURE_APP_PAGE && secureMethod is SecureAppMethod.Password -> secureMethod.checkedPassword != null - tosAndDataToggled && currentPage == TOS_AND_DATA_PAGE -> true - else -> false - } + OnboardingPages.DataProtection -> { + OnboardingPageTerms( + navController = navController, + onNextPage = { + page = OnboardingPages.SecureApp + } + ) + } - NextButton( - onNext = { - when { - currentPage == TOS_AND_DATA_PAGE && tosAndDataToggled -> onSaveNewUser() - currentPage == TOS_AND_DATA_PAGE -> { + OnboardingPages.SecureApp -> { + OnboardingSecureApp( + secureMethod = secureMethod, + onSecureMethodChange = onSecureMethodChange, + onOpenBiometricScreen = { + navController.navigate(OnboardingNavigationScreens.Biometry.path()) + }, + onNextPage = { + page = OnboardingPages.Analytics + } + ) } - else -> onNextPage() - } - }, - enabled = enabled, - modifier = modifier.align(Alignment.BottomEnd) - ) { - Crossfade(targetState = currentPage == TOS_AND_DATA_PAGE) { - when (it) { - true -> Icon(Icons.Rounded.Check, null) - false -> Icon(Icons.Rounded.ArrowForward, null) - } - } - AnimatedVisibility( - visible = currentPage == TOS_AND_DATA_PAGE - ) { - Row { - Spacer4() - Text( - stringResource(R.string.on_boarding_page_4_next).uppercase( - Locale.getDefault() + + OnboardingPages.Analytics -> { + val disAllowToast = stringResource(R.string.settings_tracking_disallow_info) + OnboardingPageAnalytics( + allowAnalytics = allowTracking, + onAllowAnalytics = { + if (!it) { + onAllowTracking(false) + createToastShort(context, disAllowToast) + } else { + navController.navigate(OnboardingNavigationScreens.Analytics.path()) + } + }, + onNextPage = { + onSaveNewUser(allowTracking, defaultProfileName, secureMethod) + } ) - ) + } } } - } -} - -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) -@Composable -private fun NextButton( - modifier: Modifier = Modifier, - enabled: Boolean, - onNext: () -> Unit, - content: @Composable RowScope.() -> Unit -) { - val backgroundColor = - if (enabled) { - MaterialTheme.colors.secondary - } else { - AppTheme.colors.neutral300 - } - val contentColor = if (enabled) { - contentColorFor(backgroundColor) - } else { - AppTheme.colors.neutral500 - } - - FloatingActionButton( - onClick = { - if (enabled) { - onNext() - } - }, - backgroundColor = backgroundColor, - contentColor = contentColor, - modifier = modifier - .testTag("onboarding/next") - .padding(bottom = 64.dp, end = 24.dp) - .semantics { - if (!enabled) { - disabled() - } + if (BuildKonfig.INTERNAL) { + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .systemBarsPadding() + .padding(PaddingDefaults.Medium) + ) { + OutlinedDebugButton( + "SKIP", + onClick = { + onSaveNewUser(false, defaultProfileName, OnboardingSecureAppMethod.Password("a", "a", 9)) + } + ) } - ) { - Row( - modifier = Modifier.padding(PaddingDefaults.Medium), - verticalAlignment = Alignment.CenterVertically - ) { - content() } } } @Composable -private fun PeopleLayer( - modifier: Modifier = Modifier, - @FloatRange(from = -1.0, to = 1.0) relativePageOffset: Float +private fun OnboardingWelcome( + onNextPage: () -> Unit ) { - Row( - modifier = modifier - .layout { measurable, constraints -> - val p = measurable.measure(constraints) - - layout(constraints.maxWidth, constraints.maxHeight) { - p.place( - x = -(p.height / 8f + p.height * relativePageOffset / 3f).roundToInt(), - y = 0 - ) - } - } - ) { - Image( - painterResource(R.drawable.onboarding_boygrannygranpa), - stringResource(R.string.on_boarding_page_1_acc_image), - alignment = Alignment.BottomStart, - modifier = Modifier.fillMaxSize() - ) + LaunchedEffect(Unit) { + delay(timeMillis = 1770) + onNextPage() } -} - -@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class) -@Composable -private fun OnboardingWelcome(modifier: Modifier, pagerState: PagerState) { - val flag = painterResource(R.drawable.ic_onboarding_logo_flag) - val gematik = painterResource(R.drawable.ic_onboarding_logo_gematik) - val eRpLogo = painterResource(R.drawable.erp_logo) - val header = stringResource(R.string.app_name) - val body = stringResource(R.string.on_boarding_page_1_headline) Column( - modifier = modifier.testTag("onboarding/welcome") + modifier = Modifier + .testTag(TestTag.Onboarding.WelcomeScreen) + .padding(horizontal = PaddingDefaults.Medium) + .systemBarsPadding() ) { Row( modifier = Modifier - .padding(start = 24.dp, top = 40.dp) + .padding( + top = PaddingDefaults.Medium + ) .align(Alignment.Start), verticalAlignment = Alignment.CenterVertically ) { - Image(flag, null, modifier = Modifier.padding(end = 10.dp)) - Icon(gematik, null, tint = AppTheme.colors.primary900) - } - - Image( - eRpLogo, null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = PaddingDefaults.XLarge) - .testId("onb_img_erp_logo") - ) - - Text( - text = header, - style = MaterialTheme.typography.h4, - color = AppTheme.colors.primary900, - fontWeight = FontWeight.W700, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = PaddingDefaults.Small) - .testId("onb_txt_start_title") - ) - Text( - text = body, - style = MaterialTheme.typography.subtitle1, - color = AppTheme.colors.neutral600, - fontWeight = FontWeight.W500, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 56.dp) - ) - - val offset by derivedStateOf { - if (pagerState.currentPage == WELCOME_PAGE) pagerState.currentPageOffset else 0f + Image( + painterResource(R.drawable.ic_onboarding_logo_flag), + null, + modifier = Modifier.padding(end = 10.dp) + ) + Icon( + painterResource(R.drawable.ic_onboarding_logo_gematik), + null, + tint = AppTheme.colors.primary900 + ) } - - PeopleLayer( - relativePageOffset = offset - ) - } -} - -@Composable -private fun OnboardingAppFeatures(modifier: Modifier) { - val image = painterResource(R.drawable.woman_red_shirt_circle_blue) - val header = stringResource(R.string.on_boarding_page_3_header) - - val imageAcc = stringResource(R.string.on_boarding_page_3_acc_image) - Column( - modifier = modifier - .testTag("onboarding/features") - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - Image( - image, - imageAcc, - alignment = Alignment.Center, + Column( modifier = Modifier - .padding(top = 40.dp) .fillMaxWidth() - .align(Alignment.CenterHorizontally) - ) - - Text( - text = header, - style = MaterialTheme.typography.h6, - color = AppTheme.colors.primary900, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(start = 24.dp, end = 24.dp) - .testId("onb_txt_features_title") - ) - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(top = 8.dp, start = 24.dp, end = 24.dp, bottom = 136.dp) + .wrapContentHeight() + .semantics(mergeDescendants = true) {} ) { - OnboardingCheck(stringResource(R.string.on_boarding_page_3_info_check_1)) - OnboardingCheck(stringResource(R.string.on_boarding_page_3_info_check_2)) - OnboardingCheck(stringResource(R.string.on_boarding_page_3_info_check_3)) + Image( + painterResource(R.drawable.erp_logo), + null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = PaddingDefaults.Large) + ) + Text( + text = stringResource(R.string.app_name), + style = AppTheme.typography.h4, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Small + ) + ) + Text( + text = stringResource(R.string.on_boarding_page_1_header), + style = AppTheme.typography.subtitle1l, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + bottom = PaddingDefaults.XXLarge + ) + ) } - } -} -@Composable -private fun OnboardingCheck(text: String) { - Row { - Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green600) - Spacer16() - Text(text, style = MaterialTheme.typography.body1) + @Suppress("MagicNumber") + Image( + painterResource(R.drawable.onboarding_boygrannygranpa), + null, + alignment = Alignment.BottomStart, + modifier = Modifier.fillMaxSize().offset(x = (-60).dp) + ) } } @Composable private fun OnboardingPageAnalytics( - modifier: Modifier, - allowTracking: Boolean, - onAllowTracking: (Boolean) -> Unit + allowAnalytics: Boolean, + onAllowAnalytics: (Boolean) -> Unit, + onNextPage: () -> Unit ) { - val header = stringResource(R.string.on_boarding_page_5_header) - val subHeader = stringResource(R.string.on_boarding_page_5_sub_header) - - Column( - modifier = modifier - .testTag("onboarding/analytics") + OnboardingScaffold( + state = rememberLazyListState(), + bottomBar = { + OnboardingBottomBar( + info = stringResource(R.string.onboarding_analytics_bottom_you_can_change), + buttonText = stringResource(R.string.onboarding_bottom_button_next), + buttonEnabled = true, + buttonModifier = Modifier.testTag(TestTag.Onboarding.NextButton), + onButtonClick = onNextPage + ) + }, + modifier = Modifier + .visualTestTag(TestTag.Onboarding.AnalyticsScreen) .fillMaxSize() - .verticalScroll(rememberScrollState()) ) { - Text( - text = header, - style = MaterialTheme.typography.h6, - color = AppTheme.colors.primary900, - modifier = Modifier - .padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = PaddingDefaults.Small) - .testId("onb_txt_tracking_headline") - ) - Text( - text = subHeader, - style = MaterialTheme.typography.subtitle1, - color = AppTheme.colors.neutral999, - modifier = Modifier - .padding( - top = PaddingDefaults.Medium, - start = 24.dp, - end = 24.dp, - bottom = PaddingDefaults.Small + item { + SpacerXXLarge() + Text( + text = stringResource(R.string.onb_page_5_header), + style = AppTheme.typography.h4, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Start, + modifier = Modifier + .padding( + top = PaddingDefaults.XXLarge, + bottom = PaddingDefaults.Large + ) + ) + SpacerXXLarge() + } + item { + Column(verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium)) { + Text( + text = stringResource(R.string.onboarding_analytics_we_want), + style = AppTheme.typography.subtitle1 ) - ) - - val stringBold = stringResource(R.string.on_boarding_page_5_anonym) - AnalyticsInfo( - icon = Icons.Rounded.Timeline, - id = R.string.on_boarding_page_5_info_1, - stringBold = stringBold - ) - Spacer16() - AnalyticsInfo( - icon = Icons.Rounded.BugReport, - id = R.string.on_boarding_page_5_info_2, - stringBold = stringBold - ) - Spacer16() - AnalyticsInfo( - icon = Icons.Rounded.LiveHelp, - id = R.string.on_boarding_page_5_info_3, - stringBold = "" - ) - Spacer40() - AnalyticsToggle(allowTracking, onAllowTracking) - SpacerSmall() - Text( - stringResource(R.string.on_boarding_page_5_label_info), - style = AppTheme.typography.body2l, - color = AppTheme.colors.neutral600, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = PaddingDefaults.Large) - ) + AnalyticsInfo( + icon = Icons.Rounded.Star, + text = stringResource(R.string.onboarding_analytics_ww_usability) + ) + AnalyticsInfo( + icon = Icons.Rounded.FlashOn, + text = stringResource(R.string.onboarding_analytics_ww_errors) + ) + AnalyticsInfo( + icon = Icons.Rounded.PersonPin, + text = stringResource(R.string.onboarding_analytics_ww_anon) + ) + } + SpacerXXLarge() + } + item { + AnalyticsToggle(allowAnalytics, onAllowAnalytics) + SpacerMedium() + } } } @Composable private fun OnboardingPageTerms( - modifier: Modifier, navController: NavController, - onBothToggled: (Boolean) -> Unit, + onNextPage: () -> Unit ) { - val header = stringResource(R.string.on_boarding_page_4_header) - val info = stringResource(R.string.on_boarding_page_4_info) + var accepted by rememberSaveable { mutableStateOf(false) } - Column( - modifier = modifier - .testTag("onboarding/terms") + OnboardingScaffold( + state = rememberLazyListState(), + bottomBar = { + OnboardingBottomBar( + modifier = Modifier.fillMaxWidth(), + info = null, + buttonText = stringResource(R.string.onboarding_bottom_button_accept), + buttonEnabled = accepted, + buttonModifier = Modifier.testTag(TestTag.Onboarding.NextButton), + onButtonClick = onNextPage + ) + }, + modifier = Modifier + .visualTestTag(TestTag.Onboarding.DataTermsScreen) .fillMaxSize() - .verticalScroll(rememberScrollState()) ) { - Text( - text = header, - style = MaterialTheme.typography.h6, - color = AppTheme.colors.primary900, - modifier = Modifier - .padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = 8.dp) - .testId("onb_txt_legal_info_title") - ) - Text( - text = info, - style = MaterialTheme.typography.body1, - modifier = Modifier - .padding(start = 24.dp, end = 24.dp) - ) - - var checkedDataProtection by rememberSaveable { mutableStateOf(false) } - var checkedTos by rememberSaveable { mutableStateOf(false) } - - DisposableEffect(checkedDataProtection, checkedTos) { - if (checkedDataProtection && checkedTos) { - onBothToggled(true) - } else { - onBothToggled(false) - } - onDispose { } + item { + SpacerXXLarge() + Image( + painter = painterResource(R.drawable.paragraph), + contentDescription = null, + alignment = Alignment.CenterStart, + modifier = Modifier.fillMaxWidth() + ) + SpacerXXLarge() } - - Spacer24() - - Column( - modifier = Modifier.padding( - start = 24.dp, - end = 24.dp, - bottom = 136.dp + item { + Text( + text = stringResource(R.string.onb_page_4_header), + style = AppTheme.typography.h4, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Start, + modifier = Modifier.padding(bottom = PaddingDefaults.Medium, top = PaddingDefaults.XXLarge) ) - ) { - OnboardingToggle( - stringResource(R.string.on_boarding_page_4_info_dataprotection), - stringResource(R.string.onb_accept_data), - toggleTestId = "onb_btn_accept_privacy", - checked = checkedDataProtection, - onCheckedChange = { - checkedDataProtection = it - }, - onClickInfo = { + SpacerMedium() + } + item { + SecondaryButton( + modifier = Modifier.fillMaxWidth().testTag(TestTag.Onboarding.DataTerms.OpenDataProtectionButton), + onClick = { navController.navigate(OnboardingNavigationScreens.DataProtection.path()) } - ) - OnboardingToggle( - stringResource(R.string.on_boarding_page_4_info_tos), - stringResource(R.string.onb_accept_tos), - toggleTestId = "onb_btn_accept_terms_of_use", - checked = checkedTos, - onCheckedChange = { - checkedTos = it - }, - onClickInfo = { + ) { + Text(stringResource(R.string.onboarding_data_button)) + } + SpacerMedium() + } + item { + SecondaryButton( + modifier = Modifier.fillMaxWidth().testTag(TestTag.Onboarding.DataTerms.OpenTermsOfUseButton), + onClick = { navController.navigate(OnboardingNavigationScreens.TermsOfUse.path()) } + ) { + Text(stringResource(R.string.onboarding_terms_button)) + } + SpacerXXLarge() + } + item { + DataTermsToggle( + accepted = accepted, + onCheckedChange = { + accepted = it + } ) + SpacerMedium() } } } @Composable -private fun AnalyticsInfo(icon: ImageVector, @StringRes id: Int, stringBold: String) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(horizontal = PaddingDefaults.Large) - ) { - Icon(icon, null, tint = AppTheme.colors.primary500) - Column(modifier = Modifier.weight(1.0f)) { - Text( - text = annotatedStringResource( - id, - annotatedStringBold(stringBold) - ), - style = MaterialTheme.typography.body1 - ) - } +private fun AnalyticsInfo(icon: ImageVector, text: String) { + Row(Modifier.fillMaxWidth()) { + Icon(icon, null, tint = AppTheme.colors.primary600) + SpacerMedium() + Text( + text = text, + style = AppTheme.typography.body1 + ) } } @@ -913,362 +660,59 @@ private fun AnalyticsInfo(icon: ImageVector, @StringRes id: Int, stringBold: Str private fun AnalyticsToggle( analyticsAllowed: Boolean, onCheckedChange: (Boolean) -> Unit -) { - val labelText = stringResource(R.string.on_boarding_page_5_label) +) = + LargeToggle( + modifier = Modifier.testTag(TestTag.Onboarding.AnalyticsSwitch), + text = stringResource(R.string.on_boarding_page_5_label), + checked = analyticsAllowed, + onCheckedChange = onCheckedChange + ) +@Composable +private fun DataTermsToggle( + accepted: Boolean, + onCheckedChange: (Boolean) -> Unit +) = + LargeToggle( + modifier = Modifier.testTag(TestTag.Onboarding.DataTerms.AcceptDataTermsSwitch), + text = stringResource(R.string.onboarding_data_terms_info), + checked = accepted, + onCheckedChange = onCheckedChange + ) + +@Composable +private fun LargeToggle( + modifier: Modifier = Modifier, + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { Row( - modifier = Modifier - .padding(horizontal = PaddingDefaults.Large) - .clip(RoundedCornerShape(16.dp)) + modifier = modifier + .clip(RoundedCornerShape(PaddingDefaults.Medium)) .background(AppTheme.colors.neutral100, shape = RoundedCornerShape(16.dp)) .fillMaxWidth() .toggleable( - value = analyticsAllowed, + value = checked, onValueChange = onCheckedChange, enabled = true, role = Role.Switch, interactionSource = remember { MutableInteractionSource() }, indication = LocalIndication.current ) - .padding(PaddingDefaults.Medium) - .semantics(true) {}, + .padding(PaddingDefaults.Medium), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) ) { - Text( - labelText, - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.weight(1f) - ) - SpacerSmall() Switch( - checked = analyticsAllowed, - onCheckedChange = null, - ) - } -} - -private sealed class SecureAppMethod { - @Parcelize - data class Password(val password: String, val repeatedPassword: String, val score: Int) : - SecureAppMethod(), - Parcelable { - val checkedPassword: String? - get() = - if (checkPassword(password, repeatedPassword, score)) { - password - } else { - null - } - } - - @Parcelize - object DeviceSecurity : SecureAppMethod(), Parcelable - - @Parcelize - object None : SecureAppMethod(), Parcelable -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -private fun OnboardingSecureApp( - modifier: Modifier, - isReturningUser: Boolean = false, - secureMethod: SecureAppMethod, - onSecureMethodChange: (SecureAppMethod) -> Unit -) { - val password = - remember(secureMethod) { (secureMethod as? SecureAppMethod.Password)?.password ?: "" } - val repeatedPassword = - remember(secureMethod) { - (secureMethod as? SecureAppMethod.Password)?.repeatedPassword ?: "" - } - val passwordScore = - remember(secureMethod) { - (secureMethod as? SecureAppMethod.Password)?.score ?: 0 - } - - var passwordFieldIsFocused by remember { mutableStateOf(false) } - val extendPassword = passwordFieldIsFocused || password.isNotEmpty() - - val header = stringResource(R.string.on_boarding_secure_app_page_header) - val info = stringResource(R.string.on_boarding_secure_app_page_info) - - Column( - modifier = modifier - .testTag("onboarding/secureAppPage") - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = PaddingDefaults.Large, vertical = PaddingDefaults.XXLarge) - ) { - - if (isReturningUser) { - Image( - painterResource(R.drawable.laptop_woman_blue), - null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxSize() - ) - SpacerMedium() - } - - Text( - text = header, - style = MaterialTheme.typography.h6, - color = AppTheme.colors.primary900, - textAlign = TextAlign.Center - ) - - if (isReturningUser) { - SpacerMedium() - - Text( - text = info, - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral999, - ) - } - - Spacer(modifier = Modifier.height(PaddingDefaults.XXLarge)) - - val focusRequester = FocusRequester.Default - val focusManager = LocalFocusManager.current - - PasswordTextField( - modifier = Modifier - .testTag("onboarding/secure_text_input_1") - .fillMaxWidth() - .onFocusChanged { - passwordFieldIsFocused = it.isFocused - }, - value = password, - onValueChange = { - if (it.isEmpty()) { - onSecureMethodChange(SecureAppMethod.None) - } else { - onSecureMethodChange( - SecureAppMethod.Password( - password = it, - repeatedPassword = "", - score = passwordScore - ) - ) - } - }, - onSubmit = { focusRequester.requestFocus() }, - allowAutofill = true, - allowVisiblePassword = true, - label = { - Text(stringResource(R.string.settings_password_enter_password)) - } + checked = checked, + onCheckedChange = null ) - AnimatedVisibility(visible = extendPassword) { - Column { - SpacerTiny() - PasswordStrength( - modifier = Modifier.fillMaxWidth(), - password = password, - onScoreChange = { - onSecureMethodChange( - SecureAppMethod.Password( - password = password, - repeatedPassword = repeatedPassword, - score = it - ) - ) - } - ) - - SpacerMedium() - - ConfirmationPasswordTextField( - modifier = Modifier - .testTag("onboarding/secure_text_input_2") - .fillMaxWidth() - .focusRequester(focusRequester), - password = password, - value = repeatedPassword, - passwordScore = passwordScore, - onValueChange = { - onSecureMethodChange( - SecureAppMethod.Password( - password = password, - repeatedPassword = it, - score = passwordScore - ) - ) - }, - onSubmit = { focusManager.clearFocus() } - ) - } - } - Row( - modifier = Modifier.padding(vertical = PaddingDefaults.XXLarge), - verticalAlignment = Alignment.CenterVertically - ) { - Divider(modifier = Modifier.weight(0.5f)) - Text( - stringResource(R.string.onboarding_secure_app_or).uppercase(Locale.getDefault()), - modifier = Modifier.padding(horizontal = 12.dp), - style = AppTheme.typography.body2l, - fontWeight = FontWeight.Medium - ) - Divider(modifier = Modifier.weight(0.5f)) - } - - var showBiometricPrompt by rememberSaveable { mutableStateOf(false) } - var showAcceptDeviceAuthenticationInfo by rememberSaveable { mutableStateOf(false) } - - if (showAcceptDeviceAuthenticationInfo) { - CommonAlertDialog( - header = stringResource(R.string.settings_biometric_dialog_title), - info = stringResource(R.string.settings_biometric_dialog_text), - actionText = stringResource(R.string.settings_device_security_allow), - cancelText = stringResource(R.string.cancel), - onCancel = { showAcceptDeviceAuthenticationInfo = false }, - onClickAction = { - showBiometricPrompt = true - showAcceptDeviceAuthenticationInfo = false - } - ) - } - - if (showBiometricPrompt) { - BiometricPrompt( - authenticationMethod = SettingsAuthenticationMethod.DeviceSecurity, - title = stringResource(R.string.auth_prompt_headline), - description = "", - negativeButton = stringResource(R.string.auth_prompt_cancel), - onAuthenticated = { - onSecureMethodChange(SecureAppMethod.DeviceSecurity) - showBiometricPrompt = false - }, - onCancel = { - showBiometricPrompt = false - }, - onAuthenticationError = { - showBiometricPrompt = false - }, - onAuthenticationSoftError = { - } - ) - } - - val buttonColors = if (secureMethod == SecureAppMethod.DeviceSecurity) { - ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.green600, - contentColor = AppTheme.colors.neutral000 - ) - } else { - ButtonDefaults.buttonColors() - } - - LargeButton( - onClick = { - showAcceptDeviceAuthenticationInfo = true - }, - colors = buttonColors - ) { - if (secureMethod == SecureAppMethod.DeviceSecurity) { - Icon(Icons.Rounded.Check, null) - SpacerSmall() - Text( - stringResource(R.string.onboarding_secure_app_button_best_chosen).uppercase( - Locale.getDefault() - ) - ) - } else { - Text(stringResource(R.string.onboarding_secure_app_button_best).uppercase(Locale.getDefault())) - } - } SpacerSmall() Text( - stringResource(R.string.onboarding_secure_app_button_best_info), - style = AppTheme.typography.body2l - ) - } -} - -@Composable -private fun OnboardingToggle( - which: String, - toggleContentDescription: String, - toggleTestId: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - onClickInfo: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - val info = annotatedStringResource( - R.string.on_boarding_page_4_info_accept_info, - buildAnnotatedString { - pushStringAnnotation("CLICKABLE", "") - pushStyle(SpanStyle(color = AppTheme.colors.primary500)) - append(which) - pop() - pop() - } - ) - - val alpha = remember { Animatable(0.0f) } - - LaunchedEffect(checked) { - if (checked) { - alpha.animateTo(1.0f) - } else { - alpha.animateTo(0.0f) - } - } - - Text( - text = info, - style = MaterialTheme.typography.body1, - modifier = Modifier - .weight(1f) - .clickable( - onClickLabel = which, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onClickInfo - ) + text = text, + style = AppTheme.typography.subtitle2, + modifier = Modifier.weight(1f) ) - - Box( - modifier = Modifier - .align(Alignment.CenterVertically) - .size(48.dp) - .toggleable( - value = checked, - onValueChange = onCheckedChange, - role = Role.Checkbox, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 24.dp - ) - ) - .testTag(toggleTestId) - .testId(toggleTestId) - .semantics { - contentDescription = toggleContentDescription - }, - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Rounded.RadioButtonUnchecked, null, - tint = AppTheme.colors.neutral400 - ) - Icon( - Icons.Rounded.CheckCircle, null, - tint = AppTheme.colors.primary600, - modifier = Modifier.alpha(alpha.value) - ) - } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingProfile.kt b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingProfile.kt deleted file mode 100644 index d4df8d10..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingProfile.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.onboarding.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.ProfileNameInputField -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer32 -import de.gematik.ti.erp.app.utils.compose.Spacer8 - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun OnboardingProfile( - modifier: Modifier = Modifier, - isReturningUser: Boolean = false, - profileName: String, - onProfileNameChange: (String) -> Unit, - onNext: () -> Unit -) { - val focusRequester = remember { FocusRequester() } - - val header = stringResource(R.string.onboarding_profile_header) - val info = stringResource(R.string.onboarding_profile_info) - - Column( - modifier = modifier - .testTag("onboarding/profilePage") - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = PaddingDefaults.Large, vertical = PaddingDefaults.XXLarge) - ) { - - Text( - text = header, - style = MaterialTheme.typography.h6, - color = AppTheme.colors.primary900, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(PaddingDefaults.XXLarge)) - - var profileNameError by remember { mutableStateOf(false) } - val keyboardController = LocalSoftwareKeyboardController.current - - ProfileNameInputField( - modifier = Modifier - .testTag("onboarding/profile_text_input") - .fillMaxWidth() - .focusRequester(focusRequester), - value = profileName, - onValueChange = { - onProfileNameChange(it) - profileNameError = it.isEmpty() - }, - onSubmit = { - if (!profileNameError) { - keyboardController?.hide() - onNext() - } - }, - label = { - Text(stringResource(R.string.onboarding_profile_input_name)) - }, - isError = profileNameError, - colors = TextFieldDefaults.outlinedTextFieldColors(textColor = AppTheme.colors.neutral999) - ) - - Spacer8() - if (profileNameError) { - Text( - text = stringResource(R.string.edit_profile_empty_profile_name), - color = AppTheme.colors.red600, - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(start = PaddingDefaults.Medium) - ) - Spacer16() - } - - Text( - text = info, - style = MaterialTheme.typography.body2, - color = AppTheme.colors.neutral600, - ) - if (isReturningUser) { - Spacer32() - Row { - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { onNext() }, - enabled = profileName.isNotEmpty() - ) { - Text(text = stringResource(id = R.string.profile_setup_save).uppercase()) - } - } - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingScaffold.kt b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingScaffold.kt new file mode 100644 index 00000000..68e8bcd6 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/onboarding/ui/OnboardingScaffold.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.onboarding.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerShortMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall + +@Composable +fun OnboardingScaffold( + modifier: Modifier = Modifier, + state: LazyListState, + bottomBar: @Composable () -> Unit, + content: LazyListScope.() -> Unit +) { + Scaffold( + modifier.systemBarsPadding(), + bottomBar = bottomBar + ) { innerPadding -> + val contentPadding by derivedStateOf { + PaddingValues( + bottom = innerPadding.calculateBottomPadding(), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + } + OnboardingLazyColumn( + modifier = Modifier.fillMaxSize(), + state = state, + content = content, + contentPadding = contentPadding + ) + } +} + +@Composable +fun OnboardingLazyColumn( + modifier: Modifier = Modifier, + contentPadding: PaddingValues, + state: LazyListState, + content: LazyListScope.() -> Unit +) { + LazyColumn( + state = state, + modifier = modifier.testTag(TestTag.Onboarding.ScreenContent), + contentPadding = contentPadding, + content = content + ) +} + +@Composable +fun OnboardingBottomBar( + modifier: Modifier = Modifier, + info: String?, + buttonText: String, + buttonEnabled: Boolean, + buttonModifier: Modifier, + onButtonClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(AppTheme.colors.neutral000) + .padding(horizontal = PaddingDefaults.Medium) + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SpacerShortMedium() + if (info != null) { + Text(info, style = AppTheme.typography.caption1l, textAlign = TextAlign.Center) + SpacerSmall() + } + PrimaryButtonSmall( + modifier = buttonModifier, + enabled = buttonEnabled, + onClick = onButtonClick + ) { + Text(buttonText) + } + SpacerLarge() + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/OderHealthCardModule.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/OderHealthCardModule.kt new file mode 100644 index 00000000..8a0fa889 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/OderHealthCardModule.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orderhealthcard + +import de.gematik.ti.erp.app.orderhealthcard.usecase.HealthCardOrderUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.instance + +val orderHealthCardModule = DI.Module("orderHealthCardModule") { + bindProvider { HealthCardOrderUseCase(instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt index 387de439..624268c2 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderComponents.kt @@ -18,29 +18,31 @@ package de.gematik.ti.erp.app.orderhealthcard.ui +import android.net.Uri import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Text @@ -52,31 +54,34 @@ import androidx.compose.material.icons.filled.PhoneInTalk import androidx.compose.material.icons.rounded.ArrowRight import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.orderhealthcard.ui.model.HealthCardOrderViewModelData import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData +import de.gematik.ti.erp.app.settings.ui.openMailClient import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold @@ -91,13 +96,13 @@ import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge import de.gematik.ti.erp.app.utils.compose.navigationModeState -import kotlinx.coroutines.flow.collect +import org.kodein.di.compose.rememberViewModel @Composable fun HealthCardContactOrderScreen( - onBack: () -> Unit, - healthCardOrderViewModel: HealthCardOrderViewModel = hiltViewModel() + onBack: () -> Unit ) { + val healthCardOrderViewModel by rememberViewModel() val state by produceState(healthCardOrderViewModel.defaultState) { healthCardOrderViewModel.screenState().collect { value = it @@ -114,31 +119,23 @@ fun HealthCardContactOrderScreen( startDestination = HealthCardOrderNavigationScreens.HealthCardOrder.path() ) { composable(HealthCardOrderNavigationScreens.HealthCardOrder.route) { - val scrollState = rememberScrollState() + val listState = rememberLazyListState() NavigationAnimation(mode = navigationMode) { AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Settings.OrderEgk.OrderEgkScreen), topBarTitle = title, - elevated = scrollState.value > 0, + elevated = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0, navigationMode = NavigationBarMode.Close, - onBack = onBack + onBack = onBack, + actions = {} ) { - Box( - Modifier - .verticalScroll(scrollState) - .padding( - rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.systemBars, - applyBottom = true - ) - ) - ) { - HealthCardOrder( - state = state, - onClickInsuranceSelector = { navController.navigate(HealthCardOrderNavigationScreens.HealthCardOrderInsuranceCompanies.path()) }, - onSelectOption = { healthCardOrderViewModel.onSelectContactOption(it) } - ) - } + HealthCardOrder( + listState = listState, + state = state, + onClickInsuranceSelector = { navController.navigate(HealthCardOrderNavigationScreens.HealthCardOrderInsuranceCompanies.path()) }, + onSelectOption = { healthCardOrderViewModel.onSelectContactOption(it) } + ) } } } @@ -147,18 +144,24 @@ fun HealthCardContactOrderScreen( NavigationAnimation(mode = navigationMode) { AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Settings.InsuranceCompanyList.InsuranceSelectionScreen), + navigationMode = NavigationBarMode.Back, topBarTitle = title, elevated = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0, - navigationMode = NavigationBarMode.Back, - onBack = { navController.popBackStack() } - ) { - HealthInsuranceSelector( - state = listState, - insuranceCompanies = state.companies, - selected = state.selectedCompany, - onSelectionChange = { healthCardOrderViewModel.onSelectInsuranceCompany(it) } - ) - } + onBack = { navController.popBackStack() }, + content = { + HealthInsuranceSelector( + state = listState, + insuranceCompanies = state.companies, + selected = state.selectedCompany, + onSelectionChange = { + healthCardOrderViewModel.onSelectInsuranceCompany(it) + navController.popBackStack() + } + ) + }, + actions = {} + ) } } } @@ -173,7 +176,11 @@ private fun HealthInsuranceSelectorPreview() { healthCardAndPinPhone = null, healthCardAndPinMail = null, healthCardAndPinUrl = null, - pinUrl = null + pinUrl = null, + subjectCardAndPinMail = null, + bodyCardAndPinMail = null, + subjectPinMail = null, + bodyPinMail = null ) } AppTheme { @@ -195,11 +202,8 @@ private fun HealthInsuranceSelector( ) { LazyColumn( state = state, - modifier = Modifier.fillMaxSize(), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) + modifier = Modifier.fillMaxSize().testTag(TestTag.Settings.InsuranceCompanyList.InsuranceSelectionContent), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() ) { items(insuranceCompanies) { company -> HealthInsuranceCompanySelectable( @@ -225,10 +229,11 @@ private fun HealthInsuranceCompanySelectable( onValueChange = onSelectionChange ) .heightIn(min = 56.dp) - .padding(PaddingDefaults.Medium), + .padding(PaddingDefaults.Medium) + .testTag(TestTag.Settings.InsuranceCompanyList.ListOfInsuranceButtons), verticalAlignment = Alignment.CenterVertically ) { - Text(name, style = MaterialTheme.typography.body1, modifier = Modifier.weight(1f)) + Text(name, style = AppTheme.typography.body1, modifier = Modifier.weight(1f)) if (selected) { SpacerMedium() Icon(Icons.Rounded.Check, null, tint = AppTheme.colors.primary600) @@ -238,98 +243,114 @@ private fun HealthInsuranceCompanySelectable( @Composable private fun HealthCardOrder( + listState: LazyListState = rememberLazyListState(), state: HealthCardOrderViewModelData.State, onClickInsuranceSelector: () -> Unit, onSelectOption: (HealthCardOrderViewModelData.ContactInsuranceOption) -> Unit ) { - Column(Modifier.fillMaxWidth()) { - Column(Modifier.padding(PaddingDefaults.Medium)) { - Text( - stringResource(R.string.cdw_health_insurance_title), - style = MaterialTheme.typography.h5, - textAlign = TextAlign.Center - ) - SpacerLarge() - Text( - stringResource(R.string.cdw_health_insurance_body_what_you_need), - style = MaterialTheme.typography.body1 - ) - SpacerSmall() - Text(stringResource(R.string.cdw_health_insurance_body_how_to_get), style = MaterialTheme.typography.body1) - SpacerSmall() - Text( - stringResource(R.string.cdw_health_insurance_caption_recognize_healthcard), - style = AppTheme.typography.body2l - ) - SpacerSmall() - HintTextLearnMoreButton( - modifier = Modifier.align(Alignment.End), - uri = stringResource(R.string.cdw_health_insurance_learn_more), - align = Alignment.End - ) - } - - SpacerXXLarge() - - Text( - stringResource(R.string.cdw_health_insurance_select_company), - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) - ) - SpacerMedium() + var onlyScrollOnce by remember { mutableStateOf(true) } - // insurance company selection button - TextButton( - onClick = onClickInsuranceSelector, - contentPadding = PaddingValues(vertical = PaddingDefaults.Medium, horizontal = PaddingDefaults.Small), - modifier = Modifier.padding(horizontal = PaddingDefaults.Small) - ) { - Text(state.selectedCompany?.name ?: stringResource(R.string.cdw_health_insurance_no_company_selected)) - Spacer(Modifier.weight(1f)) - Icon(Icons.Rounded.ArrowRight, null) + LaunchedEffect(state.selectedCompany != null) { + if (state.selectedCompany != null && onlyScrollOnce) { + onlyScrollOnce = false + listState.animateScrollToItem(2) } + } - if (state.selectedCompany != null) { - SpacerXXLarge() - if (state.selectedCompany.noContactInformation()) { - NoContactsHint() - } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().testTag(TestTag.Settings.OrderEgk.OrderEgkContent), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + Column(Modifier.padding(PaddingDefaults.Medium)) { Text( - stringResource(R.string.cdw_health_insurance_what_to_do), - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + stringResource(R.string.cdw_health_insurance_title), + style = AppTheme.typography.h5, + textAlign = TextAlign.Center, + fontWeight = FontWeight.W700 ) + SpacerLarge() + Text( + stringResource(R.string.cdw_health_insurance_body_what_you_need), + style = AppTheme.typography.body1 + ) + SpacerSmall() + Text(stringResource(R.string.cdw_health_insurance_body_how_to_get), style = AppTheme.typography.body1) - SpacerMedium() - ContactInsuranceOptions( - company = state.selectedCompany, - selected = state.selectedOption, - onSelectionChange = onSelectOption + SpacerSmall() + HintTextLearnMoreButton( + modifier = Modifier.testTag(TestTag.Settings.OrderEgk.NFCExplanationPageLink).align(Alignment.End), + uri = stringResource(R.string.cdw_health_insurance_learn_more), + align = Alignment.End ) + } + } + + item { + SpacerXXLarge() + + Text( + stringResource(R.string.cdw_health_insurance_select_company), + style = AppTheme.typography.h6, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) + SpacerMedium() + + // insurance company selection button + TextButton( + onClick = onClickInsuranceSelector, + contentPadding = PaddingValues(vertical = PaddingDefaults.Medium, horizontal = PaddingDefaults.Small), + modifier = Modifier.padding(horizontal = PaddingDefaults.Small) + .testTag(TestTag.Settings.OrderEgk.ChooseInsuranceButton) + ) { + Text(state.selectedCompany?.name ?: stringResource(R.string.cdw_health_insurance_no_company_selected)) + Spacer(Modifier.weight(1f)) + Icon(Icons.Rounded.ArrowRight, null) + } + } + item { + if (state.selectedCompany != null) { + SpacerXXLarge() + if (state.selectedCompany.noContactInformation()) { + NoContactsHint() + } else { + Text( + stringResource(R.string.cdw_health_insurance_what_to_do), + style = AppTheme.typography.h6, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) + + SpacerMedium() + ContactInsuranceOptions( + company = state.selectedCompany, + selected = state.selectedOption, + onSelectionChange = onSelectOption + ) - if (state.selectedOption != HealthCardOrderViewModelData.ContactInsuranceOption.None) { SpacerXXLarge() Text( stringResource(R.string.cdw_health_insurance_contact_insurance_company), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) ) SpacerMedium() ContactInsurance( - company = state.selectedCompany, option = state.selectedOption + company = state.selectedCompany, + option = state.selectedOption ) } } + SpacerMedium() } - - SpacerMedium() } } @Composable private fun NoContactsHint() = HintCard( - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + .testTag(TestTag.Settings.ContactInsuranceCompany.NoContactInfoTextBox), properties = HintCardDefaults.flatPropertiesAlert(), image = { HintSmallImage( @@ -349,12 +370,14 @@ private fun ContactInsuranceOptions( ) { Column { Option( + testTag = TestTag.Settings.ContactInsuranceCompany.OrderEgkAndPinRadioButton, name = stringResource(R.string.cdw_health_insurance_contact_healthcard_pin), enabled = company.hasContactInfoForHealthCardAndPin(), selected = selected == HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin, onSelect = { onSelectionChange(HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin) } ) Option( + testTag = TestTag.Settings.ContactInsuranceCompany.OrderPinRadioButton, name = stringResource(R.string.cdw_health_insurance_contact_pin_only), enabled = company.hasContactInfoForPin(), selected = selected == HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly, @@ -367,18 +390,22 @@ private fun ContactInsuranceOptions( private fun Option( name: String, enabled: Boolean, + testTag: String, selected: Boolean, onSelect: () -> Unit ) { Row(Modifier.padding(PaddingDefaults.Medium), verticalAlignment = Alignment.CenterVertically) { Text( name, - style = MaterialTheme.typography.body1, + style = AppTheme.typography.body1, color = if (enabled) Color.Unspecified else AppTheme.colors.neutral400 ) Spacer(Modifier.weight(1f)) RadioButton( - selected = selected, enabled = enabled, onClick = onSelect, + modifier = Modifier.testTag(testTag), + selected = selected, + enabled = enabled, + onClick = onSelect, colors = RadioButtonDefaults.colors( selectedColor = AppTheme.colors.primary600, unselectedColor = AppTheme.colors.neutral400, @@ -397,14 +424,20 @@ private fun ContactInsurance( ContactMethodRow( phone = company.healthCardAndPinPhone, url = company.healthCardAndPinUrl, - mail = company.healthCardAndPinMail + mail = company.healthCardAndPinMail, + company = company, + option = option ) } if (option == HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly) { ContactMethodRow( phone = null, url = company.pinUrl, - mail = null + mail = if (company.hasMailContentForPin()) { + company.healthCardAndPinMail + } else { null }, + company = company, + option = option ) } } @@ -414,6 +447,8 @@ private fun ContactMethodRow( phone: String?, url: String?, mail: String?, + company: HealthCardOrderUseCaseData.HealthInsuranceCompany, + option: HealthCardOrderViewModelData.ContactInsuranceOption ) { val uriHandler = LocalUriHandler.current @@ -425,31 +460,40 @@ private fun ContactMethodRow( ) { phone?.let { ContactMethod( - modifier = Modifier.weight(1f), - name = "Telefon", + modifier = Modifier.weight(1f).testTag(TestTag.Settings.ContactInsuranceCompany.TelephoneButton), + name = stringResource(R.string.healthcard_order_phone), icon = Icons.Filled.PhoneInTalk, onClick = { - uriHandler.openUri("tel:$it") + uriHandler.openUri("tel:$phone") } ) } url?.let { ContactMethod( - modifier = Modifier.weight(1f), - name = "Website", + modifier = Modifier.weight(1f).testTag(TestTag.Settings.ContactInsuranceCompany.WebsiteButton), + name = stringResource(R.string.healthcard_order_website), icon = Icons.Filled.OpenInBrowser, onClick = { - uriHandler.openUri(it) + uriHandler.openUri(url) } ) } mail?.let { + val context = LocalContext.current ContactMethod( - modifier = Modifier.weight(1f), - name = "Mail", + modifier = Modifier.weight(1f).testTag(TestTag.Settings.ContactInsuranceCompany.MailToButton), + name = stringResource(R.string.healthcard_order_mail), icon = Icons.Filled.MailOutline, onClick = { - uriHandler.openUri("mailto:$it?subject=$mailSubject") + when { + option == HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin && + company.hasMailContentForCardAndPin() -> openMailClient(context = context, address = mail, subject = company.subjectCardAndPinMail!!, body = company.bodyCardAndPinMail!!) + + option == HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly && + company.hasMailContentForPin() -> openMailClient(context = context, address = mail, subject = company.subjectPinMail!!, body = company.bodyPinMail!!) + + else -> uriHandler.openUri("mailto:$mail?subject=${Uri.encode(mailSubject)}") + } } ) } @@ -465,18 +509,17 @@ private fun ContactMethod( onClick: () -> Unit ) { Card( - modifier = modifier, onClick = onClick, - contentColor = AppTheme.colors.primary600, - role = Role.Button, + modifier = modifier, shape = RoundedCornerShape(8.dp), + contentColor = AppTheme.colors.primary600, border = BorderStroke(1.dp, AppTheme.colors.neutral300), elevation = 0.dp ) { Column(Modifier.padding(PaddingDefaults.Medium), horizontalAlignment = Alignment.CenterHorizontally) { Icon(icon, null) SpacerSmall() - Text(name, style = MaterialTheme.typography.subtitle2) + Text(name, style = AppTheme.typography.subtitle2) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt index f5e03787..744d1696 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/HealthCardOrderViewModel.kt @@ -18,30 +18,27 @@ package de.gematik.ti.erp.app.orderhealthcard.ui -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.Route -import de.gematik.ti.erp.app.core.BaseViewModel +import androidx.lifecycle.ViewModel import de.gematik.ti.erp.app.orderhealthcard.ui.model.HealthCardOrderViewModelData import de.gematik.ti.erp.app.orderhealthcard.usecase.HealthCardOrderUseCase import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import javax.inject.Inject object HealthCardOrderNavigationScreens { object HealthCardOrder : Route("HealthCardOrder") object HealthCardOrderInsuranceCompanies : Route("HealthCardOrderInsuranceCompanies") } -@HiltViewModel -class HealthCardOrderViewModel @Inject constructor( +class HealthCardOrderViewModel( private val healthCardOrderUseCase: HealthCardOrderUseCase -) : BaseViewModel() { +) : ViewModel() { val defaultState = HealthCardOrderViewModelData.State( companies = emptyList(), selectedCompany = null, - selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.None, + selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin ) private val state = MutableStateFlow(defaultState) @@ -54,7 +51,7 @@ class HealthCardOrderViewModel @Inject constructor( fun onSelectInsuranceCompany(company: HealthCardOrderUseCaseData.HealthInsuranceCompany) { state.value = state.value.copy( selectedCompany = company, - selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.None + selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt index dd8a93dd..d1eb8bb1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/ui/model/HealthCardOrderViewModelData.kt @@ -30,6 +30,6 @@ object HealthCardOrderViewModelData { ) enum class ContactInsuranceOption { - None, WithHealthCardAndPin, PinOnly + WithHealthCardAndPin, PinOnly } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt index 33a93e00..0e9e0592 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCase.kt @@ -19,18 +19,18 @@ package de.gematik.ti.erp.app.orderhealthcard.usecase import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.orderhealthcard.usecase.model.HealthCardOrderUseCaseData import kotlinx.coroutines.flow.flow +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import java.io.InputStream -import javax.inject.Inject -class HealthCardOrderUseCase @Inject constructor( - @ApplicationContext private val context: Context, +class HealthCardOrderUseCase( + private val context: Context ) { private val companies: List by lazy { - loadHealthInsuranceContactsFromCSV( + loadHealthInsuranceContactsFromJSON( context.resources.openRawResourceFd(R.raw.health_insurance_contacts).createInputStream() ).sortedBy { it.name.lowercase() } } @@ -40,30 +40,5 @@ class HealthCardOrderUseCase @Inject constructor( } } -fun loadHealthInsuranceContactsFromCSV(csv: InputStream): List { - return csv.bufferedReader().useLines { lines -> - lines.mapIndexedNotNull { index, line -> - if (index > 0) { - // ignore header - - val attrs = line.split(";").map { - if (it.isBlank()) { - null - } else { - it - } - } - - HealthCardOrderUseCaseData.HealthInsuranceCompany( - name = requireNotNull(attrs[0]), - healthCardAndPinPhone = attrs[1], - healthCardAndPinMail = attrs[2], - healthCardAndPinUrl = attrs[3], - pinUrl = attrs[4] - ) - } else { - null - } - }.toList() - } -} +fun loadHealthInsuranceContactsFromJSON(jsonInput: InputStream): List = + Json.decodeFromString(jsonInput.bufferedReader().readText()) diff --git a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt index 978b5334..aaf5ed02 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/orderhealthcard/usecase/model/HealthInsuranceCompany.kt @@ -19,26 +19,36 @@ package de.gematik.ti.erp.app.orderhealthcard.usecase.model import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable object HealthCardOrderUseCaseData { @Immutable + @Serializable data class HealthInsuranceCompany( val name: String, val healthCardAndPinPhone: String?, val healthCardAndPinMail: String?, val healthCardAndPinUrl: String?, val pinUrl: String?, + val subjectCardAndPinMail: String?, + val bodyCardAndPinMail: String?, + val subjectPinMail: String?, + val bodyPinMail: String? ) { fun noContactInformation() = - healthCardAndPinPhone == null && - healthCardAndPinMail == null && - healthCardAndPinUrl == null && - pinUrl == null + healthCardAndPinPhone.isNullOrEmpty() && + healthCardAndPinMail.isNullOrEmpty() && + healthCardAndPinUrl.isNullOrEmpty() && + pinUrl.isNullOrEmpty() fun hasContactInfoForPin() = - pinUrl != null + !pinUrl.isNullOrEmpty() || (!bodyPinMail.isNullOrEmpty() && !subjectPinMail.isNullOrEmpty()) fun hasContactInfoForHealthCardAndPin() = - healthCardAndPinPhone != null || healthCardAndPinMail != null || healthCardAndPinUrl != null + !healthCardAndPinPhone.isNullOrEmpty() || !healthCardAndPinMail.isNullOrEmpty() || !healthCardAndPinUrl.isNullOrEmpty() + + fun hasMailContentForCardAndPin() = !subjectCardAndPinMail.isNullOrEmpty() && !bodyCardAndPinMail.isNullOrEmpty() + + fun hasMailContentForPin() = !subjectPinMail.isNullOrEmpty() && !bodyPinMail.isNullOrEmpty() } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/MessagesModule.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/MessagesModule.kt new file mode 100644 index 00000000..1b8d7e9c --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/MessagesModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders + +import de.gematik.ti.erp.app.orders.repository.CommunicationLocalDataSource +import de.gematik.ti.erp.app.orders.repository.CommunicationRepository +import de.gematik.ti.erp.app.orders.repository.PharmacyCacheLocalDataSource +import de.gematik.ti.erp.app.orders.repository.PharmacyCacheRemoteDataSource +import de.gematik.ti.erp.app.orders.usecase.OrderUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.instance + +val messagesModule = DI.Module("messagesModule") { + bindProvider { PharmacyCacheLocalDataSource(instance()) } + bindProvider { PharmacyCacheRemoteDataSource(instance()) } + bindProvider { CommunicationLocalDataSource(instance()) } + bindProvider { CommunicationRepository(instance(), instance(), instance(), instance(), instance(), instance()) } + bindProvider { OrderUseCase(instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationLocalDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationLocalDataSource.kt new file mode 100644 index 00000000..5c438797 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationLocalDataSource.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +@file:Suppress("SpreadOperator") + +package de.gematik.ti.erp.app.orders.repository + +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.prescription.repository.toCommunication +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import io.realm.kotlin.query.Sort +import io.realm.kotlin.query.max +import io.realm.kotlin.types.RealmInstant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class CommunicationLocalDataSource( + private val realm: Realm +) { + + fun loadDispReqCommunications( + orderId: String + ): Flow> = + realm.query( + "orderId = $0 && _profile = $1", + orderId, + SyncedTaskData.CommunicationProfile.ErxCommunicationDispReq.toEntityValue() + ) + .asFlow() + .map { communication -> + communication.list.mapNotNull { + it.toCommunication() + } + } + + fun loadFirstDispReqCommunications( + profileId: ProfileIdentifier + ): Flow> = + realm.query( + "parent.parent.id = $0 && _profile = $1", + profileId, + SyncedTaskData.CommunicationProfile.ErxCommunicationDispReq.toEntityValue() + ) + .sort("sentOn", Sort.DESCENDING) + .distinct("orderId") + .asFlow() + .map { communications -> + communications.list.mapNotNull { + it.toCommunication() + } + } + + fun loadRepliedCommunications( + taskIds: List + ): Flow> = + realm.query( + orQuerySubstring("parent.taskId", taskIds.size), + *taskIds.toTypedArray() + ) + .query("_profile = $0", SyncedTaskData.CommunicationProfile.ErxCommunicationReply.toEntityValue()) + .sort("sentOn", Sort.DESCENDING) + .distinct("payload") + .asFlow() + .map { communications -> + communications.list.mapNotNull { + it.toCommunication() + } + } + + fun hasUnreadMessages(taskIds: List, orderId: String): Flow = + realm.query( + orQuerySubstring("parent.taskId", taskIds.size), + *taskIds.toTypedArray() + ) + .query("consumed = false && orderId = $0", orderId) + .count() + .asFlow() + .map { it > 0 } + + fun hasUnreadMessages(profileId: ProfileIdentifier): Flow = + realm.query("consumed = false && parent.parent.id = $0", profileId) + .count() + .asFlow() + .map { it > 0 } + + private fun orQuerySubstring(field: String, count: Int): String = + (0 until count) + .map { "$field = $$it" } + .joinToString(" || ") + + fun taskIdsByOrder(orderId: String): Flow> = + realm.query( + "orderId = $0 && _profile = $1", + orderId, + SyncedTaskData.CommunicationProfile.ErxCommunicationDispReq.toEntityValue() + ) + .asFlow() + .map { result -> + result.list.map { it.taskId } + } + + suspend fun setCommunicationStatus(communicationId: String, consumed: Boolean) { + realm.write { + queryFirst("communicationId = $0", communicationId)?.apply { + this.consumed = consumed + } + } + } + + fun latestCommunicationTimestamp(profileId: ProfileIdentifier) = + realm.query("parent.parent.id = $0", profileId) + .max("sentOn") + .asFlow() + .map { + it?.toInstant() + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationRepository.kt new file mode 100644 index 00000000..f7a59962 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationRepository.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.api.ResourcePaging +import de.gematik.ti.erp.app.fhir.model.extractPharmacyServices +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import de.gematik.ti.erp.app.prescription.repository.LocalDataSource +import de.gematik.ti.erp.app.prescription.repository.RemoteDataSource +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.time.Instant + +private const val CommunicationsMaxPageSize = 50 + +class CommunicationRepository( + private val taskLocalDataSource: LocalDataSource, + private val taskRemoteDataSource: RemoteDataSource, + private val communicationLocalDataSource: CommunicationLocalDataSource, + private val cacheLocalDataSource: PharmacyCacheLocalDataSource, + private val cacheRemoteDataSource: PharmacyCacheRemoteDataSource, + private val dispatchers: DispatchProvider +) : ResourcePaging(dispatchers, CommunicationsMaxPageSize) { + private val scope = CoroutineScope(dispatchers.IO) + private val queue = Channel(capacity = Channel.BUFFERED) + + val pharmacyCacheError = MutableSharedFlow() + + init { + scope.launch { + for (telematikId in queue) { + cacheRemoteDataSource + .searchPharmacy(telematikId) + .onSuccess { + val pharmacy = extractPharmacyServices(it).pharmacies.firstOrNull() + pharmacy?.let { + cacheLocalDataSource.savePharmacy(pharmacy.telematikId, pharmacy.name) + } + } + .onFailure { + Napier.e("Failed to download pharmacy for cache with telematikId $telematikId", it) + pharmacyCacheError.tryEmit(it) + } + } + } + } + + suspend fun downloadCommunications(profileId: ProfileIdentifier) = downloadPaged(profileId) + + override suspend fun downloadResource(profileId: ProfileIdentifier, timestamp: String?, count: Int?): Result = + taskRemoteDataSource.fetchCommunications( + profileId = profileId, + count = count, + lastKnownUpdate = timestamp + ).mapCatching { communications -> + taskLocalDataSource.saveCommunications(communications) + } + + override suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? = + communicationLocalDataSource.latestCommunicationTimestamp(profileId).first() + + fun loadPharmacies(): Flow> = + cacheLocalDataSource.loadPharmacies().flowOn(dispatchers.IO) + + suspend fun downloadMissingPharmacy(telematikId: String) { + queue.send(telematikId) + } + + fun loadPrescriptionName(taskId: String) = + taskLocalDataSource.loadSyncedTaskByTaskId(taskId).map { + it?.medicationName() + }.flowOn(dispatchers.IO) + + fun loadDispReqCommunications(orderId: String) = + communicationLocalDataSource.loadDispReqCommunications(orderId).flowOn(dispatchers.IO) + + fun loadFirstDispReqCommunications(profileId: ProfileIdentifier) = + communicationLocalDataSource.loadFirstDispReqCommunications(profileId).flowOn(dispatchers.IO) + + fun loadRepliedCommunications(taskIds: List) = + communicationLocalDataSource.loadRepliedCommunications(taskIds = taskIds).flowOn(dispatchers.IO) + + fun hasUnreadMessages(taskIds: List, orderId: String) = + communicationLocalDataSource.hasUnreadMessages(taskIds, orderId).flowOn(dispatchers.IO) + + fun hasUnreadMessages(profileId: ProfileIdentifier) = + communicationLocalDataSource.hasUnreadMessages(profileId).flowOn(dispatchers.IO) + + fun taskIdsByOrder(orderId: String) = + communicationLocalDataSource.taskIdsByOrder(orderId).flowOn(dispatchers.IO) + + suspend fun setCommunicationStatus(communicationId: String, consumed: Boolean) { + withContext(dispatchers.IO) { + communicationLocalDataSource.setCommunicationStatus(communicationId, consumed) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/repository/PharmacyCacheLocalDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/PharmacyCacheLocalDataSource.kt new file mode 100644 index 00000000..23a87a8c --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/PharmacyCacheLocalDataSource.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.repository + +import de.gematik.ti.erp.app.db.entities.v1.PharmacyCacheEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +class PharmacyCacheLocalDataSource( + private val realm: Realm +) { + fun loadPharmacies() = + realm + .query() + .asFlow() + .map { result -> + result.list.map { + it.toCachedPharmacy() + } + } + .distinctUntilChanged() + + suspend fun savePharmacy(telematikId: String, name: String) { + realm.write { + realm.queryFirst("telematikId = $0", telematikId)?.apply { + this.name = name + } ?: run { + copyToRealm( + PharmacyCacheEntityV1().apply { + this.telematikId = telematikId + this.name = name + } + ) + } + } + } +} + +data class CachedPharmacy( + val name: String, + val telematikId: String +) + +fun PharmacyCacheEntityV1.toCachedPharmacy() = + CachedPharmacy( + name = this.name, + telematikId = this.telematikId + ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/repository/PharmacyCacheRemoteDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/PharmacyCacheRemoteDataSource.kt new file mode 100644 index 00000000..29563796 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/PharmacyCacheRemoteDataSource.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.repository + +import de.gematik.ti.erp.app.api.PharmacySearchService +import de.gematik.ti.erp.app.api.safeApiCall +import kotlinx.serialization.json.JsonElement + +class PharmacyCacheRemoteDataSource( + private val searchService: PharmacySearchService +) { + suspend fun searchPharmacy( + telematikId: String + ): Result = safeApiCall("error searching pharmacy by telematikId") { + if (telematikId.startsWith("3-SMC")) { + searchService.search(names = listOf(telematikId), emptyMap()) + } else { + searchService.searchByTelematikId(telematikId = telematikId) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/ui/MessageSheets.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/MessageSheets.kt new file mode 100644 index 00000000..c4df8219 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/MessageSheets.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.orders.usecase.model.OrderUseCaseData +import de.gematik.ti.erp.app.redeem.ui.DataMatrixCode +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun MessageSheetContent( + order: OrderUseCaseData.Order?, + message: OrderUseCaseData.Message?, + onClickClose: () -> Unit +) { + Column( + Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium) + .padding(bottom = PaddingDefaults.XLarge) + ) { + IconButton( + onClick = onClickClose, + modifier = Modifier.align(Alignment.End) + ) { + Box( + Modifier + .size(32.dp) + .background(AppTheme.colors.neutral100, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.Close, null, tint = AppTheme.colors.neutral600) + } + } + SpacerMedium() + order?.let { + message?.let { + Box( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + when (message.type) { + OrderUseCaseData.Message.Type.All -> + AllSheetContent(message) + + OrderUseCaseData.Message.Type.Link -> + LinkSheetContent(message) + + OrderUseCaseData.Message.Type.Code -> + CodeSheetContent(message) + + OrderUseCaseData.Message.Type.Text -> + TextSheetContent(message) + + OrderUseCaseData.Message.Type.Empty -> + EmptySheetContent(order.pharmacy.pharmacyName()) + } + } + } + } + } +} + +@Composable +private fun AllSheetContent( + message: OrderUseCaseData.Message +) { + Column(verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Large)) { + message.code?.let { + Box( + Modifier + .background(AppTheme.colors.neutral100, RoundedCornerShape(16.dp)) + .padding(PaddingDefaults.Medium) + ) { + DataMatrixCode(payload = message.code, modifier = Modifier.size(144.dp)) + } + CodeLabel(code = message.code) + } + message.message?.let { + TextSheetContent(message) + } + message.link?.let { + LinkSheetContent(message) + } + } +} + +@Composable +fun LinkSheetContent( + message: OrderUseCaseData.Message +) { + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (message.type != OrderUseCaseData.Message.Type.All) { + Text( + stringResource(R.string.orders_cart_ready), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + SpacerSmall() + Text( + stringResource(R.string.orders_cart_ready_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + SpacerLarge() + } + PrimaryButtonSmall( + onClick = { + message.link?.let { uriHandler.openUri(it) } + } + ) { + Text(stringResource(R.string.orders_open_cart_link)) + } + } +} + +@Composable +fun TextSheetContent( + message: OrderUseCaseData.Message +) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + Modifier + .fillMaxWidth() + .background(AppTheme.colors.neutral100, RoundedCornerShape(16.dp)) + .padding(PaddingDefaults.Medium) + ) { + Text( + message.message ?: "", + style = AppTheme.typography.body2 + ) + } + SpacerSmall() + Text( + sentOn(message), + style = AppTheme.typography.caption1l, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.End) + ) + } +} + +@Composable +private fun sentOn(message: OrderUseCaseData.Message): String = + remember(message) { + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + dateFormatter.format(LocalDateTime.ofInstant(message.sentOn, ZoneId.systemDefault())) + } + +@Composable +fun CodeSheetContent( + message: OrderUseCaseData.Message +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + message.code?.let { + DataMatrixCode(payload = message.code, modifier = Modifier.size(144.dp)) + SpacerMedium() + CodeLabel(code = message.code) + } + SpacerSmall() + Text( + stringResource(R.string.orders_code_title), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + SpacerSmall() + Text( + stringResource(R.string.orders_code_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun CodeLabel( + code: String +) { + Box( + Modifier + .background(AppTheme.colors.neutral100, RoundedCornerShape(8.dp)) + .padding(horizontal = PaddingDefaults.ShortMedium, vertical = PaddingDefaults.ShortMedium / 2) + ) { + Text( + code, + style = AppTheme.typography.subtitle2l, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } +} + +@Composable +fun EmptySheetContent(pharmacyName: String) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(R.string.orders_no_message_title), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + SpacerSmall() + Text( + stringResource(R.string.orders_no_message, pharmacyName), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderEmptyScreens.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderEmptyScreens.kt new file mode 100644 index 00000000..64b95e1f --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderEmptyScreens.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.ui.EmptyScreenHome +import de.gematik.ti.erp.app.prescription.ui.HomeConnectedWithoutToken +import de.gematik.ti.erp.app.prescription.ui.HomeConnectedWithoutTokenBiometrics +import de.gematik.ti.erp.app.prescription.ui.HomeHealthCardDisconnected +import de.gematik.ti.erp.app.profiles.ui.ProfileHandler +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.SpacerSmall + +@Composable +fun LazyItemScope.OrderEmptyScreen( + connectionState: ProfileHandler.ProfileConnectionState?, + onClickRefresh: () -> Unit +) { + Box( + modifier = Modifier + .fillParentMaxSize() + .padding(PaddingDefaults.Medium), + contentAlignment = Alignment.Center + ) { + when (connectionState) { + ProfileHandler.ProfileConnectionState.LoggedOut -> { + HomeHealthCardDisconnected( + onClickAction = onClickRefresh + ) + } + ProfileHandler.ProfileConnectionState.LoggedOutWithoutTokenBiometrics -> { + HomeConnectedWithoutTokenBiometrics( + onClickAction = onClickRefresh + ) + } + ProfileHandler.ProfileConnectionState.LoggedOutWithoutToken -> { + HomeConnectedWithoutToken( + onClickAction = onClickRefresh + ) + } + else -> { + NoOrders( + onClickRefresh = onClickRefresh + ) + } + } + } +} + +@Composable +private fun NoOrders( + modifier: Modifier = Modifier, + onClickRefresh: () -> Unit +) = + EmptyScreenHome( + modifier = modifier, + header = stringResource(R.string.orders_empty_title), + description = stringResource(R.string.orders_empty_subtitle), + image = { + Image( + painterResource(R.drawable.woman_red_shirt_circle_blue), + contentDescription = null, + modifier = Modifier.size(160.dp) + ) + }, + button = { + TextButton( + onClick = onClickRefresh + ) { + Icon( + Icons.Rounded.Refresh, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + SpacerSmall() + Text(text = stringResource(R.string.home_egk_redeemed_buttontext), textAlign = TextAlign.Right) + } + } + ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderScreen.kt new file mode 100644 index 00000000..6031a8a4 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderScreen.kt @@ -0,0 +1,756 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowRight +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.navigation.NavController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens +import de.gematik.ti.erp.app.mainscreen.ui.MainScreenViewModel +import de.gematik.ti.erp.app.mainscreen.ui.RefreshScaffold +import de.gematik.ti.erp.app.orders.usecase.OrderUseCase +import de.gematik.ti.erp.app.orders.usecase.model.OrderUseCaseData +import de.gematik.ti.erp.app.prescription.ui.UserNotAuthenticatedDialog +import de.gematik.ti.erp.app.prescriptionId +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.ProfileHandler +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.DynamicText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerTiny +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import de.gematik.ti.erp.app.utils.compose.timeDescription +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.kodein.di.compose.rememberInstance +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun OrderScreen( + mainNavController: NavController, + mainScreenViewModel: MainScreenViewModel, + onElevateTopBar: (Boolean) -> Unit +) { + val profileHandler = LocalProfileHandler.current + + var showUserNotAuthenticatedDialog by remember { mutableStateOf(false) } + + val onShowCardWall = { + mainNavController.navigate( + MainNavigationScreens.CardWall.path(profileHandler.activeProfile.id) + ) + } + if (showUserNotAuthenticatedDialog) { + UserNotAuthenticatedDialog( + onCancel = { showUserNotAuthenticatedDialog = false }, + onShowCardWall = onShowCardWall + ) + } + + RefreshScaffold( + profileId = profileHandler.activeProfile.id, + onUserNotAuthenticated = { showUserNotAuthenticatedDialog = true }, + mainScreenViewModel = mainScreenViewModel, + onShowCardWall = onShowCardWall + ) { onRefresh -> + Orders( + profileHandler = profileHandler, + onClickOrder = { orderId -> + mainNavController.navigate( + MainNavigationScreens.Messages.path(orderId) + ) + }, + onClickRefresh = { + onRefresh(true, MutatePriority.UserInput) + }, + onElevateTopBar = onElevateTopBar + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun MessageScreen( + orderId: String, + mainNavController: NavController +) { + val listState = rememberLazyListState() + + val state = + rememberMessageState(orderId = orderId) + + val order by state.order + + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden, + confirmStateChange = { it != ModalBottomSheetValue.HalfExpanded } + ) + val scope = rememberCoroutineScope() + var selectedMessage: OrderUseCaseData.Message? by remember { mutableStateOf(null) } + + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + MessageSheetContent( + order = order, + message = selectedMessage, + onClickClose = { scope.launch { sheetState.hide() } } + ) + }, + sheetShape = remember { RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) } + ) { + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Orders.Details.Screen), + topBarTitle = stringResource(R.string.orders_details_title), + listState = listState, + navigationMode = NavigationBarMode.Back, + onBack = { + scope.launch { + state.consumeAllMessages() + mainNavController.popBackStack() + } + } + ) { + Messages( + listState = listState, + messageState = state, + onClickMessage = { + selectedMessage = it + scope.launch { sheetState.animateTo(ModalBottomSheetValue.Expanded) } + }, + onClickPrescription = { + mainNavController.navigate( + MainNavigationScreens.PrescriptionDetail.path(taskId = it) + ) + } + ) + } + } + + BackHandler { + scope.launch { + state.consumeAllMessages() + mainNavController.popBackStack() + } + } +} + +@Stable +class MessageState( + orderId: String, + private val orderUseCase: OrderUseCase, + coroutineScope: CoroutineScope +) { + enum class States { + LoadingMessages, + HasMessages, + NoMessages + } + + var state by mutableStateOf(States.LoadingMessages) + private set + + private val messageFlow = orderUseCase + .messages(orderId) + .onEach { + state = if (it.isEmpty()) { + States.NoMessages + } else { + States.HasMessages + } + } + .shareIn(coroutineScope, SharingStarted.Lazily, 1) + + val messages + @Composable + get() = messageFlow + .collectAsState(emptyList()) + + private val orderFlow = orderUseCase + .order(orderId) + .shareIn(coroutineScope, SharingStarted.Lazily, 1) + + val order + @Composable + get() = orderFlow + .collectAsState(null) + + suspend fun consumeAllMessages() { + withContext(NonCancellable) { + orderFlow.first()?.let { + if (it.hasUnreadMessages) { + orderUseCase.consumeOrder(it.orderId) + messageFlow.first().forEach { + orderUseCase.consumeCommunication(it.communicationId) + } + } + } + } + } +} + +@Composable +fun rememberMessageState( + orderId: String +): MessageState { + val orderUseCase by rememberInstance() + val coroutineScope = rememberCoroutineScope() + return remember(orderId) { + MessageState( + orderId = orderId, + orderUseCase = orderUseCase, + coroutineScope = coroutineScope + ) + } +} + +@Stable +class OrderState( + profileIdentifier: ProfileIdentifier, + orderUseCase: OrderUseCase +) { + enum class States { + LoadingOrders, + HasOrders, + NoOrders + } + + var state by mutableStateOf(States.LoadingOrders) + private set + + private val orderFlow = orderUseCase + .orders(profileIdentifier) + .onEach { + state = if (it.isEmpty()) { + States.NoOrders + } else { + States.HasOrders + } + } + + // keep; implementation follows + val errorFlow = orderUseCase.pharmacyCacheError + + val orders + @Composable + get() = orderFlow.collectAsState(emptyList()) +} + +@Composable +fun rememberOrderState( + profileIdentifier: ProfileIdentifier +): OrderState { + val orderUseCase by rememberInstance() + return remember(profileIdentifier) { + OrderState(profileIdentifier, orderUseCase) + } +} + +@Composable +private fun Orders( + profileHandler: ProfileHandler, + onClickOrder: (orderId: String) -> Unit, + onClickRefresh: () -> Unit, + onElevateTopBar: (Boolean) -> Unit +) { + val listState = rememberLazyListState() + val activeProfile = profileHandler.activeProfile + val orderState = rememberOrderState(activeProfile.id) + val orders by orderState.orders + + LaunchedEffect(Unit) { + snapshotFlow { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + }.collect { + onElevateTopBar(it) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(TestTag.Orders.Content), + state = listState + ) { + when (orderState.state) { + OrderState.States.LoadingOrders -> { + // keep empty + item {} + } + + OrderState.States.HasOrders -> { + orders.forEachIndexed { index, order -> + item { + val sentOn by timeDescription(order.sentOn) + Order( + pharmacy = order.pharmacy.pharmacyName(), + time = sentOn, + hasUnreadMessages = order.hasUnreadMessages, + nrOfPrescriptions = order.taskIds.size, + onClick = { + onClickOrder(order.orderId) + } + ) + if (index < orders.size - 1) { + Divider(Modifier.padding(start = PaddingDefaults.Medium)) + } + } + } + } + + OrderState.States.NoOrders -> { + item { + val connectionState = profileHandler.connectionState(activeProfile) + OrderEmptyScreen(connectionState, onClickRefresh = onClickRefresh) + } + } + } + } +} + +@Composable +fun Order( + pharmacy: String, + time: String, + hasUnreadMessages: Boolean, + nrOfPrescriptions: Int, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(PaddingDefaults.Medium) + .fillMaxWidth() + .testTag(TestTag.Orders.OrderListItem), + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(pharmacy, style = AppTheme.typography.subtitle1) + SpacerTiny() + Text(time, style = AppTheme.typography.body2l) + } + if (hasUnreadMessages) { + NewLabel() + } else { + PrescriptionLabel(nrOfPrescriptions) + } + Icon(Icons.Rounded.KeyboardArrowRight, contentDescription = null, tint = AppTheme.colors.neutral400) + } +} + +@Composable +fun NewLabel() { + Box( + Modifier + .clip(CircleShape) + .background(AppTheme.colors.primary100) + .padding(horizontal = PaddingDefaults.Small, vertical = 3.dp), + contentAlignment = Alignment.Center + ) { + Text( + stringResource(R.string.orders_label_new), + style = AppTheme.typography.caption2, + color = AppTheme.colors.primary900 + ) + } +} + +@Composable +fun PrescriptionLabel(count: Int) { + Box( + Modifier + .clip(CircleShape) + .background(AppTheme.colors.neutral100) + .padding(horizontal = PaddingDefaults.Small, vertical = 3.dp), + contentAlignment = Alignment.Center + ) { + Text( + annotatedPluralsResource( + R.plurals.orders_plurals_label_nr_of_prescriptions, + count, + AnnotatedString(count.toString()) + ), + style = AppTheme.typography.caption2, + color = AppTheme.colors.neutral600 + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun Messages( + listState: LazyListState, + messageState: MessageState, + onClickMessage: (OrderUseCaseData.Message) -> Unit, + onClickPrescription: (String) -> Unit +) { + val order by messageState.order + val messages by messageState.messages + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(TestTag.Orders.Details.Content), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + Text( + stringResource(R.string.orders_history_title), + style = AppTheme.typography.h6, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) + SpacerMedium() + } + + when (messageState.state) { + MessageState.States.HasMessages -> { + messages.forEachIndexed { index, message -> + item { + ReplyMessage( + message = message, + isFirstMessage = index == 0, + onClick = { + onClickMessage(message) + } + ) + } + } + } + + else -> {} + } + + order?.let { + item { + DispenseMessage( + hasReplyMessages = messages.isNotEmpty(), + order = it + ) + SpacerXXLarge() + } + } + + item { + Divider(color = AppTheme.colors.neutral300) + SpacerXXLarge() + Text( + stringResource(R.string.orders_cart_title), + style = AppTheme.typography.h6, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) + } + + order?.let { + item(key = "prescriptions") { + Column( + Modifier.padding(PaddingDefaults.Medium), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + it.medicationNames.forEachIndexed { index, med -> + Surface( + modifier = Modifier + .testTag(TestTag.Orders.Details.PrescriptionListItem) + .semantics { + prescriptionId = it.taskIds[index] + }, + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, AppTheme.colors.neutral300), + color = AppTheme.colors.neutral050, + onClick = { + onClickPrescription(it.taskIds[index]) + } + ) { + Row( + Modifier.padding(PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + med, + style = AppTheme.typography.subtitle1, + modifier = Modifier.weight(1f) + ) + SpacerMedium() + Icon( + Icons.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = AppTheme.colors.neutral400 + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ReplyMessage( + message: OrderUseCaseData.Message, + isFirstMessage: Boolean, + onClick: () -> Unit +) { + val info = when (message.type) { + OrderUseCaseData.Message.Type.Link -> stringResource(R.string.orders_show_cart) + OrderUseCaseData.Message.Type.Code -> stringResource(R.string.orders_show_code) + OrderUseCaseData.Message.Type.Text -> null + else -> stringResource(R.string.orders_show_general_message) + } + val description = when (message.type) { + OrderUseCaseData.Message.Type.Text -> message.message ?: "" + else -> null + } + + val date = remember(message) { + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) + dateFormatter.format(LocalDateTime.ofInstant(message.sentOn, ZoneId.systemDefault())) + } + val time = remember(message) { + val dateFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + dateFormatter.format(LocalDateTime.ofInstant(message.sentOn, ZoneId.systemDefault())) + } + + Column( + Modifier + .drawConnectedLine( + top = !isFirstMessage, + bottom = true + ) + .clickable( + onClick = onClick, + enabled = message.type != OrderUseCaseData.Message.Type.Text + ) + .fillMaxWidth() + ) { + Row { + Spacer(Modifier.width(48.dp)) + Column( + Modifier + .weight(1f) + .padding(PaddingDefaults.Medium) + ) { + Text( + stringResource(R.string.orders_timestamp, date, time), + style = AppTheme.typography.subtitle2 + ) + description?.let { + SpacerTiny() + Text( + text = it, + style = AppTheme.typography.body2l + ) + } + info?.let { + SpacerTiny() + val txt = buildAnnotatedString { + append(it) + append(" ") + appendInlineContent("button", "button") + } + val c = mapOf( + "button" to InlineTextContent( + Placeholder( + width = 0.em, + height = 0.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon( + Icons.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = AppTheme.colors.primary600 + ) + } + ) + DynamicText( + txt, + style = AppTheme.typography.body2, + color = AppTheme.colors.primary600, + inlineContent = c + ) + } + } + } + Divider(Modifier.padding(start = 64.dp)) + } +} + +@Composable +private fun DispenseMessage( + order: OrderUseCaseData.Order, + hasReplyMessages: Boolean +) { + val date = remember(order) { + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) + dateFormatter.format(LocalDateTime.ofInstant(order.sentOn, ZoneId.systemDefault())) + } + val time = remember(order) { + val dateFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + dateFormatter.format(LocalDateTime.ofInstant(order.sentOn, ZoneId.systemDefault())) + } + Row( + Modifier.drawConnectedLine( + top = true, + bottom = false, + topDashed = !hasReplyMessages + ) + ) { + Spacer(Modifier.width(48.dp)) + Column( + Modifier + .weight(1f) + .padding(PaddingDefaults.Medium) + ) { + Text( + stringResource(R.string.orders_timestamp, date, time), + style = AppTheme.typography.subtitle2 + ) + SpacerTiny() + val highlightedPharmacyName = buildAnnotatedString { + withStyle(SpanStyle(color = AppTheme.colors.primary600)) { + append(order.pharmacy.pharmacyName()) + } + } + Text( + text = annotatedStringResource(R.string.orders_prescription_sent_to, highlightedPharmacyName), + style = AppTheme.typography.body2l + ) + } + } +} + +@Composable +fun OrderUseCaseData.Pharmacy.pharmacyName() = + name.ifBlank { + stringResource(R.string.orders_generic_pharmacy_name) + } + +private fun Modifier.drawConnectedLine( + top: Boolean, + bottom: Boolean, + topDashed: Boolean = false +) = composed { + val color = AppTheme.colors.neutral300 + val background = AppTheme.colors.neutral000 + + drawBehind { + val center = Offset(x = 24.dp.toPx(), y = center.y) + val start = if (top) { + Offset(x = center.x, y = 0f) + } else { + center + } + val end = if (bottom) { + Offset(x = center.x, y = size.height) + } else { + center + } + drawLine( + color = color, + strokeWidth = 2.dp.toPx(), + start = start, + end = end, + pathEffect = if (topDashed) PathEffect.dashPathEffect(floatArrayOf(5.dp.toPx(), 2.dp.toPx())) else null + ) + drawCircle(color = color, center = center, radius = 8.dp.toPx()) + drawCircle(color = background, center = center, radius = 3.dp.toPx()) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/usecase/OrderUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/usecase/OrderUseCase.kt new file mode 100644 index 00000000..7ef73bf6 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/usecase/OrderUseCase.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.usecase + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.orders.usecase.model.OrderUseCaseData +import de.gematik.ti.erp.app.orders.repository.CommunicationRepository +import de.gematik.ti.erp.app.pharmacy.repository.model.CommunicationPayloadInbox +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.SerializationException +import java.net.URI + +@OptIn(ExperimentalCoroutinesApi::class) +class OrderUseCase( + private val repository: CommunicationRepository, + private val dispatchers: DispatchProvider +) { + val pharmacyCacheError = repository.pharmacyCacheError + + fun orders(profileIdentifier: ProfileIdentifier): Flow> = + combine( + repository.loadFirstDispReqCommunications(profileIdentifier), + repository.loadPharmacies() + ) { communications, pharmacies -> + communications.map { communication -> + dispReqCommunicationToOrder( + communication = communication, + withMedicationNames = false, + pharmacyName = pharmacies.find { it.telematikId == communication.recipient }?.name + ) + } + } + + fun order(orderId: String): Flow = + combine( + repository.loadDispReqCommunications(orderId), + repository.loadPharmacies() + ) { communications, pharmacies -> + communications.firstOrNull()?.let { communication -> + dispReqCommunicationToOrder( + communication = communication, + withMedicationNames = true, + pharmacyName = pharmacies.find { it.telematikId == communication.recipient }?.name + ) + } + } + + private suspend fun dispReqCommunicationToOrder( + communication: SyncedTaskData.Communication, + withMedicationNames: Boolean, + pharmacyName: String? + ): OrderUseCaseData.Order { + val taskIds = repository.taskIdsByOrder(communication.orderId).first() + val hasUnreadMessages = repository.hasUnreadMessages(taskIds, communication.orderId).first() + val medicationNames = if (withMedicationNames) { + taskIds.map { + repository.loadPrescriptionName(it).first() ?: "" + } + } else { + emptyList() + } + + if (pharmacyName == null) { + repository.downloadMissingPharmacy(communication.recipient) + } + + return communication.toOrder( + medicationNames = medicationNames, + hasUnreadMessages = hasUnreadMessages, + taskIds = taskIds, + pharmacyName = pharmacyName + ) + } + + fun messages( + orderId: String + ): Flow> = + repository.taskIdsByOrder(orderId).flatMapLatest { + repository.loadRepliedCommunications(taskIds = it) + .map { communications -> + communications.map { it.toMessage() } + } + } + + fun unreadCommunicationsAvailable(profileId: ProfileIdentifier) = + repository.hasUnreadMessages(profileId).flowOn(dispatchers.IO) + + suspend fun consumeCommunication(communicationId: String) { + withContext(dispatchers.IO) { + repository.setCommunicationStatus(communicationId, true) + } + } + + suspend fun consumeOrder(orderId: String) { + withContext(dispatchers.IO) { + repository.loadDispReqCommunications(orderId).first().forEach { + repository.setCommunicationStatus(it.communicationId, true) + } + } + } +} + +private val lenientJson = Json { + isLenient = true + ignoreUnknownKeys = true +} + +fun SyncedTaskData.Communication.toOrder( + medicationNames: List, + hasUnreadMessages: Boolean, + taskIds: List, + pharmacyName: String? +) = + OrderUseCaseData.Order( + orderId = orderId, + taskIds = taskIds, + medicationNames = medicationNames, + sentOn = sentOn, + pharmacy = OrderUseCaseData.Pharmacy(name = pharmacyName ?: "", id = this.recipient), + hasUnreadMessages = hasUnreadMessages + ) + +fun SyncedTaskData.Communication.toMessage() = + payload?.let { + try { + val inbox = lenientJson.decodeFromString(payload) + + OrderUseCaseData.Message( + communicationId = communicationId, + sentOn = sentOn, + message = inbox.infoText?.ifBlank { null }, + code = inbox.pickUpCodeDMC?.ifBlank { null } ?: inbox.pickUpCodeHR?.ifBlank { null }, + link = inbox.url?.ifBlank { null }?.takeIf { isValidUrl(it) }, + consumed = consumed + ) + } catch (ignored: SerializationException) { + OrderUseCaseData.Message( + communicationId = communicationId, + sentOn = sentOn, + message = null, + code = null, + link = null, + consumed = consumed + ) + } + } ?: OrderUseCaseData.Message( + communicationId = communicationId, + sentOn = sentOn, + message = null, + code = null, + link = null, + consumed = consumed + ) + +/** + * Every url should be valid and the scheme is `https`. + */ +fun isValidUrl(url: String): Boolean = + try { + URI.create(url).scheme == "https" + } catch (_: IllegalArgumentException) { + false + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/orders/usecase/model/OrderUseCaseData.kt b/android/src/main/java/de/gematik/ti/erp/app/orders/usecase/model/OrderUseCaseData.kt new file mode 100644 index 00000000..ebddcd4e --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/usecase/model/OrderUseCaseData.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.usecase.model + +import java.time.Instant + +object OrderUseCaseData { + data class Pharmacy( + val id: String, + val name: String + ) + + data class Order( + val orderId: String, + val taskIds: List, + val medicationNames: List, + val sentOn: Instant, + val pharmacy: Pharmacy, + val hasUnreadMessages: Boolean + ) + + data class Message( + val communicationId: String, + val sentOn: Instant, + val message: String?, + val code: String?, + val link: String?, + val consumed: Boolean + ) { + enum class Type { + All, + Link, + Code, + Text, + Empty + } + + val type: Type = run { + var filled = 0 + link?.let { filled++ } + code?.let { filled++ } + message?.let { filled++ } + + if (filled == 0) { + Type.Empty + } else if (filled > 1) { + Type.All + } else { + when { + link != null -> Type.Link + code != null -> Type.Code + message != null -> Type.Text + else -> Type.All + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyDirectCommunication.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyDirectCommunication.kt new file mode 100644 index 00000000..448fc3c4 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyDirectCommunication.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy + +import de.gematik.ti.erp.app.BCProvider +import org.bouncycastle.asn1.ASN1EncodableVector +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.ASN1PrintableString +import org.bouncycastle.asn1.ASN1Sequence +import org.bouncycastle.asn1.DERIA5String +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.DERSet +import org.bouncycastle.asn1.cms.Attribute +import org.bouncycastle.asn1.cms.AttributeTable +import org.bouncycastle.asn1.cms.IssuerAndSerialNumber +import org.bouncycastle.asn1.cms.RecipientIdentifier +import org.bouncycastle.asn1.isismtt.ISISMTTObjectIdentifiers +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cms.CMSAlgorithm +import org.bouncycastle.cms.CMSAuthEnvelopedDataGenerator +import org.bouncycastle.cms.CMSProcessableByteArray +import org.bouncycastle.cms.SimpleAttributeTableGenerator +import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder +import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator +import org.bouncycastle.operator.OutputAEADEncryptor +import org.bouncycastle.operator.jcajce.JceAsymmetricKeyWrapper +import io.github.aakira.napier.Napier +import java.security.spec.MGF1ParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource + +const val OidRecipientMail = "1.2.276.0.76.4.173" // komle-recipient-emails + +fun buildDirectPharmacyMessage( + message: String, + recipientCertificates: List +): ByteArray { + require(recipientCertificates.isNotEmpty()) { "No recipients specified!" } + + val msg = CMSProcessableByteArray(message.toByteArray()) + + val edGen = CMSAuthEnvelopedDataGenerator() + + val info = buildRecipientInfo(recipientCertificates) + + edGen.setUnauthenticatedAttributeGenerator( + SimpleAttributeTableGenerator( + AttributeTable( + Attribute( + ASN1ObjectIdentifier(OidRecipientMail), + DERSet(info) + ) + ) + ) + ) + + val jcaConverter = JcaX509CertificateConverter().apply { + setProvider(BCProvider) + } + + recipientCertificates + .filterByRSAPublicKey() + .forEach { recipientCert -> + val jcaCert = jcaConverter.getCertificate(recipientCert) + + edGen.addRecipientInfoGenerator( + JceKeyTransRecipientInfoGenerator( + jcaCert, + JceAsymmetricKeyWrapper( + OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT), + jcaCert.publicKey + ) + ).setProvider(BCProvider) + ) + } + + val contentEncryptor = JceCMSContentEncryptorBuilder(CMSAlgorithm.AES256_GCM) + .setProvider(BCProvider) + .build() + + val ed = edGen.generate(msg, contentEncryptor as OutputAEADEncryptor) + + return ed.toASN1Structure().encoded +} + +fun buildRecipientInfo(recipientCertificates: List) = + ASN1EncodableVector().apply { + recipientCertificates.forEach { recipientCert -> + add( + DERSequence( + ASN1EncodableVector().apply { + val telematikId = requireNotNull(recipientCert.extractTelematikId()) { + "Telematik ID not found!" + } + + add(DERIA5String(telematikId, true)) + add(RecipientIdentifier(IssuerAndSerialNumber(recipientCert.toASN1Structure()))) + } + ) + ) + } + } + +fun X509CertificateHolder.extractTelematikId(): String? = + try { + this + .getExtension(ISISMTTObjectIdentifiers.id_isismtt_at_admission) + .parsedValue.let { it as ASN1Sequence } // AdmissionSyntax + .find { it is ASN1Sequence }.let { it as ASN1Sequence } // contentsOfAdmissions + .getObjectAt(0).let { it as ASN1Sequence } // first one + .find { it is ASN1Sequence }.let { it as ASN1Sequence } // professionInfos + .getObjectAt(0).let { it as ASN1Sequence } // first one + .find { it is ASN1PrintableString } // registrationNumber + .let { it as ASN1PrintableString }.string + } catch (ignored: Exception) { + Napier.w("Telematik ID could not be extracted", ignored) + null + } + +fun List.filterByRSAPublicKey() = + this.filter { recipientCert -> + recipientCert.subjectPublicKeyInfo.algorithm.algorithm == PKCSObjectIdentifiers.rsaEncryption + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyModule.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyModule.kt new file mode 100644 index 00000000..8a4c926b --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy + +import de.gematik.ti.erp.app.pharmacy.repository.PharmacyLocalDataSource +import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRemoteDataSource +import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRepository +import de.gematik.ti.erp.app.pharmacy.repository.ShippingContactRepository +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyDirectRedeemUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyMapsUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.instance + +val pharmacyModule = DI.Module("pharmacyModule") { + bindProvider { PharmacyRemoteDataSource(instance(), instance()) } + bindProvider { PharmacyLocalDataSource(instance()) } + bindProvider { PharmacyRepository(instance(), instance(), instance()) } + bindProvider { ShippingContactRepository(instance(), instance()) } + bindProvider { PharmacyDirectRedeemUseCase(instance()) } + bindProvider { PharmacyMapsUseCase(instance(), instance(), instance()) } + bindProvider { PharmacySearchUseCase(instance(), instance(), instance(), instance(), instance()) } + bindProvider { PharmacyOverviewUseCase(instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/model/PharmacyData.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/model/PharmacyData.kt new file mode 100644 index 00000000..7a4d8c92 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/model/PharmacyData.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.model + +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import java.time.Instant + +object PharmacyData { + data class ShippingContact( + val name: String, + val line1: String, + val line2: String, + val postalCodeAndCity: String, + val telephoneNumber: String, + val mail: String, + val deliveryInformation: String + ) +} + +fun SyncedTaskData.SyncedTask.shippingContact() = + PharmacyData.ShippingContact( + name = this.patient.name ?: "", + line1 = this.patient.address?.line1 ?: "", + line2 = this.patient.address?.line2 ?: "", + postalCodeAndCity = this.patient.address?.postalCodeAndCity ?: "", + telephoneNumber = "", + mail = "", + deliveryInformation = "" + ) + +object OverviewPharmacyData { + data class OverviewPharmacy( + val lastUsed: Instant, + val isFavorite: Boolean, + val usageCount: Int, + val telematikId: String, + val pharmacyName: String, + val address: String + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyLocalDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyLocalDataSource.kt new file mode 100644 index 00000000..40f13a3f --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyLocalDataSource.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.repository + +import de.gematik.ti.erp.app.db.entities.v1.task.FavoritePharmacyEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OftenUsedPharmacyEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.db.tryWrite +import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import io.realm.kotlin.query.Sort +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.Instant +import javax.inject.Inject + +class PharmacyLocalDataSource @Inject constructor( + private val realm: Realm +) { + suspend fun deleteOverviewPharmacy(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { + realm.tryWrite { + queryFirst("telematikId = $0", overviewPharmacy.telematikId)?.let { + delete(it) + } + queryFirst("telematikId = $0", overviewPharmacy.telematikId)?.let { + delete(it) + } + } + } + + fun loadOftenUsedPharmacies(): Flow> = + realm.query().sort("lastUsed", Sort.DESCENDING).asFlow().map { + it.list.map { pharmacy -> + pharmacy.toOverviewPharmacy() + } + } + + suspend fun saveOrUpdateOftenUsedPharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + realm.tryWrite { + queryFirst("telematikId = $0", pharmacy.telematikId)?.apply { + this.lastUsed = Instant.now().toRealmInstant() + this.usageCount += 1 + } ?: copyToRealm(pharmacy.toOftenUsedPharmacyEntityV1()) + } + } + + fun PharmacyUseCaseData.Pharmacy.toOftenUsedPharmacyEntityV1() = + OftenUsedPharmacyEntityV1().apply { + this.address = this@toOftenUsedPharmacyEntityV1.removeLineBreaksFromAddress() + this.pharmacyName = this@toOftenUsedPharmacyEntityV1.name + this.telematikId = this@toOftenUsedPharmacyEntityV1.telematikId + } + + fun OftenUsedPharmacyEntityV1.toOverviewPharmacy() = + OverviewPharmacyData.OverviewPharmacy( + lastUsed = this.lastUsed.toInstant(), + usageCount = this.usageCount, + isFavorite = false, + telematikId = this.telematikId, + pharmacyName = this.pharmacyName, + address = this.address + ) + + suspend fun deleteFavoritePharmacy(favoritePharmacy: PharmacyUseCaseData.Pharmacy) { + realm.tryWrite { + queryFirst("telematikId = $0", favoritePharmacy.telematikId)?.let { delete(it) } + } + } + + fun loadFavoritePharmacies(): Flow> = + realm.query().sort("lastUsed", Sort.DESCENDING).asFlow().map { + it.list.map { favorite -> + favorite.toOverviewPharmacy() + } + } + + suspend fun saveOrUpdateFavoritePharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + realm.tryWrite { + queryFirst("telematikId = $0", pharmacy.telematikId)?.apply { + this.lastUsed = Instant.now().toRealmInstant() + } ?: copyToRealm(pharmacy.toFavoritePharmacyEntityV1()) + } + } + + fun isPharmacyInFavorites(telematikId: String): Boolean { + realm.queryFirst("telematikId = $0", telematikId).also { + return it != null + } + } + + fun PharmacyUseCaseData.Pharmacy.toFavoritePharmacyEntityV1() = + FavoritePharmacyEntityV1().apply { + this.address = this@toFavoritePharmacyEntityV1.removeLineBreaksFromAddress() + this.pharmacyName = this@toFavoritePharmacyEntityV1.name + this.telematikId = this@toFavoritePharmacyEntityV1.telematikId + } + + fun FavoritePharmacyEntityV1.toOverviewPharmacy() = + OverviewPharmacyData.OverviewPharmacy( + lastUsed = this.lastUsed.toInstant(), + telematikId = this.telematikId, + pharmacyName = this.pharmacyName, + address = this.address, + isFavorite = true, + usageCount = 0 + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyMapper.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyMapper.kt deleted file mode 100644 index 2c052dab..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyMapper.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.repository - -import de.gematik.ti.erp.app.pharmacy.repository.model.DeliveryPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.EmergencyPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.LocalPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.Location -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningHours -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningTime -import de.gematik.ti.erp.app.pharmacy.repository.model.Pharmacy -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyAddress -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyContacts -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacySearchResult -import de.gematik.ti.erp.app.pharmacy.repository.model.RoleCode -import de.gematik.ti.erp.app.prescription.repository.extractResources -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.CodeableConcept -import org.hl7.fhir.r4.model.ContactPoint -import org.hl7.fhir.r4.model.HealthcareService -import org.hl7.fhir.r4.model.Location.LocationStatus -import java.time.DayOfWeek -import java.time.LocalTime -import org.hl7.fhir.r4.model.Location as FhirLocation - -typealias FhirLocationHoursOfOperationComponent = FhirLocation.LocationHoursOfOperationComponent -typealias FhirHealthcareServiceAvailableTimeComponent = HealthcareService.HealthcareServiceAvailableTimeComponent - -private const val OUT_PHARMACY = "OUTPHARM" -private const val MOBL = "MOBL" - -object PharmacyMapper { - /** - * Extract pharmacy services from a search bundle. - */ - fun extractLocalPharmacyServices(bundle: Bundle): PharmacySearchResult { - val locations = bundle.extractResources() - - return PharmacySearchResult( - pharmacies = locations?.mapNotNull { location -> - runCatching { - val locationName = requireNotNull(location.name) - val localService = listOf( - LocalPharmacyService( - name = locationName, - openingHours = location.hoursOfOperation.mapToOpeningHours() - ) - ) - - val otherServices = location.contained?.mapNotNull { resource -> - (resource as? HealthcareService)?.let { healthCareService -> - when (healthCareService.typeFirstRep?.codingFirstRep?.code) { - "498" -> { - DeliveryPharmacyService( - name = locationName, - openingHours = healthCareService.availableTime.mapToOpeningHours() - ) - } - "117" -> { - EmergencyPharmacyService( - name = locationName, - openingHours = healthCareService.availableTime.mapToOpeningHours() - ) - } - else -> null - } - } - } ?: emptyList() - - Pharmacy( - name = locationName, - location = location.position.mapToLocation(), - address = location.address?.let { address -> - PharmacyAddress( - lines = address.line.mapNotNull { it.value }, - postalCode = address.postalCode ?: "", - city = address.city ?: "" - ) - } ?: PharmacyAddress(listOf(), "", ""), - contacts = location.telecom.mapToContacts(), - provides = localService + otherServices, - telematikId = requireNotNull(location.identifier?.find { it.system == "https://gematik.de/fhir/NamingSystem/TelematikID" }?.value), - roleCode = roleCodes(location.type), - ready = location.status == LocationStatus.ACTIVE - ) - }.getOrNull() - } ?: emptyList(), - bundleId = bundle.id, - bundleResultCount = bundle.total, - ) - } - - private fun roleCodes(coding: MutableList): List { - return coding.map { - when (it.coding[0].code) { - OUT_PHARMACY -> RoleCode.OUT_PHARM - MOBL -> RoleCode.MOBL - else -> RoleCode.PHARM - } - } - } - - private fun FhirLocation.LocationPositionComponent.mapToLocation(): Location = - Location( - latitude = this.latitude.toDouble(), - longitude = this.longitude.toDouble(), - ) - - private fun List.mapToContacts(): PharmacyContacts = - PharmacyContacts( - phone = this.find { it.system == ContactPoint.ContactPointSystem.PHONE }?.value ?: "", - mail = this.find { it.system == ContactPoint.ContactPointSystem.EMAIL }?.value ?: "", - url = this.find { it.system == ContactPoint.ContactPointSystem.URL }?.value ?: "" - ) - - @JvmName("mapToOpeningHoursForLocationTime") - private fun List.mapToOpeningHours() = - mapNotNull { fhirHours -> - fhirHours.daysOfWeek.mapNotNull { mapFhirDayOfWeekToDayOfWeek(it?.valueAsString) } - .takeIf { it.isNotEmpty() } - ?.let { - mapOpeningHours( - days = it, - openingTime = runCatching { LocalTime.parse(fhirHours.openingTime) }.getOrDefault( - LocalTime.MIN - ), - closingTime = runCatching { LocalTime.parse(fhirHours.closingTime) }.getOrDefault( - LocalTime.MAX - ), - ) - } - }.flatten().fold(mutableMapOf>()) { acc, v -> - acc[v.first] = acc.getOrDefault(v.first, emptyList()) + v.second - acc - }.let { - OpeningHours(it) - } - - @JvmName("mapToOpeningHoursForHealthcareServiceTime") - private fun List.mapToOpeningHours() = - mapNotNull { fhirHours -> - fhirHours.daysOfWeek.mapNotNull { mapFhirDayOfWeekToDayOfWeek(it?.valueAsString) } - .takeIf { it.isNotEmpty() } - ?.let { - mapOpeningHours( - days = it, - openingTime = runCatching { LocalTime.parse(fhirHours.availableStartTime) }.getOrDefault( - LocalTime.MIN - ), - closingTime = runCatching { LocalTime.parse(fhirHours.availableEndTime) }.getOrDefault( - LocalTime.MAX - ), - ) - } - }.flatten().fold(mutableMapOf>()) { acc, v -> - acc[v.first] = acc.getOrDefault(v.first, emptyList()) + v.second - acc - }.let { - OpeningHours(it) - } - - private fun mapFhirDayOfWeekToDayOfWeek(day: String?) = - when (day) { - "mon" -> DayOfWeek.MONDAY - "tue" -> DayOfWeek.TUESDAY - "wed" -> DayOfWeek.WEDNESDAY - "thu" -> DayOfWeek.THURSDAY - "fri" -> DayOfWeek.FRIDAY - "sat" -> DayOfWeek.SATURDAY - "sun" -> DayOfWeek.SUNDAY - else -> null - } - - private fun mapOpeningHours(days: List, openingTime: LocalTime, closingTime: LocalTime) = - days.map { - Pair(it, listOf(OpeningTime(openingTime = openingTime, closingTime = closingTime))) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt index 5dd0bb50..3dc0c5e1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRemoteDataSource.kt @@ -18,28 +18,65 @@ package de.gematik.ti.erp.app.pharmacy.repository +import de.gematik.ti.erp.app.api.PharmacyRedeemService import de.gematik.ti.erp.app.api.PharmacySearchService -import de.gematik.ti.erp.app.api.Result import de.gematik.ti.erp.app.api.safeApiCall -import org.hl7.fhir.r4.model.Bundle -import javax.inject.Inject +import kotlinx.serialization.json.JsonElement +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URL -class PharmacyRemoteDataSource @Inject constructor( - private val service: PharmacySearchService, +private const val PlaceholderTelematikId = "" +private const val PlaceholderTransactionId = "" + +class PharmacyRemoteDataSource( + private val searchService: PharmacySearchService, + private val redeemService: PharmacyRedeemService ) { suspend fun searchPharmacies( names: List, filter: Map - ): Result = safeApiCall("error searching pharmacies") { - service.search(names, filter) + ): Result = safeApiCall("error searching pharmacies") { + searchService.search(names, filter) } suspend fun searchPharmaciesContinued( bundleId: String, offset: Int, count: Int - ): Result = safeApiCall("error searching pharmacies") { - service.searchByBundle(bundleId = bundleId, offset = offset, count = count) + ): Result = safeApiCall("error searching pharmacies") { + searchService.searchByBundle(bundleId = bundleId, offset = offset, count = count) + } + + suspend fun redeemPrescription( + url: String, + message: ByteArray, + pharmacyTelematikId: String, + transactionId: String + ): Result = safeApiCall("error redeeming prescription with $url") { + val validatedUrl = url + .replace(PlaceholderTelematikId, pharmacyTelematikId, ignoreCase = true) + .replace(PlaceholderTransactionId, transactionId, ignoreCase = true) + .let { + URL(it) + } + + val messageBody = message.toRequestBody("application/pkcs7-mime".toMediaType()) + + redeemService.redeem( + url = validatedUrl.toString(), + message = messageBody + ) + } + + suspend fun searchPharmacyByTelematikId( + telematikId: String + ): Result = safeApiCall("error searching pharmacies") { + if (telematikId.startsWith("3-SMC")) { + searchService.search(names = listOf(telematikId), emptyMap()) + } else { + searchService.searchByTelematikId(telematikId = telematikId) + } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt index 8f3699e8..2af620e4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyRepository.kt @@ -18,37 +18,117 @@ package de.gematik.ti.erp.app.pharmacy.repository -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.api.map -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacySearchResult +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import kotlinx.coroutines.flow.flowOn +import de.gematik.ti.erp.app.fhir.model.PharmacyServices +import de.gematik.ti.erp.app.fhir.model.extractPharmacyServices +import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData +import io.github.aakira.napier.Napier +import kotlinx.coroutines.withContext import javax.inject.Inject class PharmacyRepository @Inject constructor( - private val remoteDataSource: PharmacyRemoteDataSource + private val remoteDataSource: PharmacyRemoteDataSource, + private val localDataSource: PharmacyLocalDataSource, + private val dispatchProvider: DispatchProvider ) { suspend fun searchPharmacies( names: List, filter: Map - ): Result = - remoteDataSource.searchPharmacies(names, filter).map { - Result.Success( - PharmacyMapper.extractLocalPharmacyServices(it), - ) - } + ): Result = + remoteDataSource.searchPharmacies(names, filter) + .map { + extractPharmacyServices( + bundle = it, + onError = { element, cause -> + Napier.e(cause) { + element.toString() + } + } + ) + } suspend fun searchPharmaciesByBundle( bundleId: String, offset: Int, count: Int - ): Result = + ): Result = remoteDataSource.searchPharmaciesContinued( bundleId = bundleId, offset = offset, count = count ).map { - Result.Success( - PharmacyMapper.extractLocalPharmacyServices(it), + extractPharmacyServices( + bundle = it, + onError = { element, cause -> + Napier.e(cause) { + element.toString() + } + } ) } + + suspend fun redeemPrescription( + url: String, + message: ByteArray, + pharmacyTelematikId: String, + transactionId: String + ): Result = + remoteDataSource.redeemPrescription( + url = url, + message = message, + pharmacyTelematikId = pharmacyTelematikId, + transactionId = transactionId + ) + + fun loadOftenUsedPharmacies() = + localDataSource.loadOftenUsedPharmacies().flowOn(dispatchProvider.IO) + + fun loadFavoritePharmacies() = + localDataSource.loadFavoritePharmacies().flowOn(dispatchProvider.IO) + + suspend fun saveOrUpdateOftenUsedPharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + withContext(dispatchProvider.IO) { + localDataSource.saveOrUpdateOftenUsedPharmacy(pharmacy) + } + } + + suspend fun deleteOverviewPharmacy(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { + withContext(dispatchProvider.IO) { + localDataSource.deleteOverviewPharmacy(overviewPharmacy) + } + } + + suspend fun saveOrUpdateFavoritePharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + withContext(dispatchProvider.IO) { + localDataSource.saveOrUpdateFavoritePharmacy(pharmacy) + } + } + + suspend fun deleteFavoritePharmacy(favoritePharmacy: PharmacyUseCaseData.Pharmacy) { + withContext(dispatchProvider.IO) { + localDataSource.deleteFavoritePharmacy(favoritePharmacy) + } + } + + suspend fun searchPharmacyByTelematikId( + telematikId: String + ): Result = + withContext(dispatchProvider.IO) { + remoteDataSource.searchPharmacyByTelematikId(telematikId) + .map { + extractPharmacyServices( + bundle = it, + onError = { element, cause -> + Napier.e(element.toString(), cause) + } + ) + } + } + + suspend fun isPharmacyInFavorites(telematikId: String): Boolean = withContext(dispatchProvider.IO) { + localDataSource.isPharmacyInFavorites(telematikId) + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/ShippingContactRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/ShippingContactRepository.kt new file mode 100644 index 00000000..2a4dca9b --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/ShippingContactRepository.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.pharmacy.model.PharmacyData +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class ShippingContactRepository( + private val dispatchers: DispatchProvider, + private val realm: Realm +) { + fun shippingContact(): Flow = + realm.query() + .first() + .asFlow() + .map { + it.obj?.shippingContact?.toShippingContact() + } + .flowOn(dispatchers.IO) + + suspend fun saveShippingContact(contact: PharmacyData.ShippingContact) { + withContext(dispatchers.IO) { + realm.write { + queryFirst()?.let { settings -> + val shippingContact = settings.shippingContact + ?: copyToRealm(ShippingContactEntityV1()).also { + settings.shippingContact = it + } + + shippingContact.let { + it.address!!.line1 = contact.line1 + it.address!!.line2 = contact.line2 + it.address!!.postalCodeAndCity = contact.postalCodeAndCity + it.name = contact.name + it.telephoneNumber = contact.telephoneNumber + it.mail = contact.mail + it.deliveryInformation = contact.deliveryInformation + } + } + } + } + } +} + +fun ShippingContactEntityV1.toShippingContact() = + PharmacyData.ShippingContact( + name = this.name, + line1 = this.address!!.line1, + line2 = this.address!!.line2, + postalCodeAndCity = this.address!!.postalCodeAndCity, + telephoneNumber = this.telephoneNumber, + mail = this.mail, + deliveryInformation = this.deliveryInformation + ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayload.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayload.kt index e69ea2be..720bf9cf 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayload.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayload.kt @@ -18,40 +18,14 @@ package de.gematik.ti.erp.app.pharmacy.repository.model -import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable -@JsonClass(generateAdapter = true) +@Serializable data class CommunicationPayload( val version: String = "1", val supplyOptionsType: String, val name: String, - val address: Array, + val address: List, val hint: String = "", val phone: String? -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as CommunicationPayload - - if (version != other.version) return false - if (supplyOptionsType != other.supplyOptionsType) return false - if (name != other.name) return false - if (!address.contentEquals(other.address)) return false - if (hint != other.hint) return false - if (phone != other.phone) return false - - return true - } - - override fun hashCode(): Int { - var result = version.hashCode() - result = 31 * result + supplyOptionsType.hashCode() - result = 31 * result + name.hashCode() - result = 31 * result + address.contentHashCode() - result = 31 * result + hint.hashCode() - result = 31 * result + phone.hashCode() - return result - } -} +) diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayloadInbox.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayloadInbox.kt index 2da7dbb0..89667f4e 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayloadInbox.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/CommunicationPayloadInbox.kt @@ -18,15 +18,27 @@ package de.gematik.ti.erp.app.pharmacy.repository.model -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -@JsonClass(generateAdapter = true) +@Serializable +enum class SupplyOptionsType { + @SerialName("shipment") + Shipment, + + @SerialName("onPremise") + OnPremise, + + @SerialName("delivery") + Delivery +} + +@Serializable data class CommunicationPayloadInbox( val version: String = "1", - val supplyOptionsType: String, - @Json(name = "info_text") val infoText: String, - val url: String?, - val pickUpCodeHR: String?, - val pickUpCodeDMC: String? + val supplyOptionsType: SupplyOptionsType, + @SerialName("info_text") val infoText: String? = null, + val url: String? = null, + val pickUpCodeHR: String? = null, + val pickUpCodeDMC: String? = null ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacyOverviewViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacyOverviewViewModel.kt new file mode 100644 index 00000000..3e42d788 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacyOverviewViewModel.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.repository.model + +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf + +class PharmacyOverviewViewModel( + private val pharmacyUseCase: PharmacyOverviewUseCase +) : ViewModel() { + fun pharmacyOverviewState() = combine( + pharmacyUseCase.favoritePharmacies(), + pharmacyUseCase.oftenUsedPharmacies() + ) { favorites, oftenUsed -> + favorites + oftenUsed.filterNot { oftenUsedPharmacy -> + favorites.any { + it.telematikId == oftenUsedPharmacy.telematikId + } + } + } + + suspend fun deleteOverviewPharmacy(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { + pharmacyUseCase.deleteOverviewPharmacy(overviewPharmacy) + } + + suspend fun findPharmacyByTelematikIdState( + telematikId: String + ) = flowOf(pharmacyUseCase.searchPharmacyByTelematikId(telematikId)) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/CourierDeliveryComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/CourierDeliveryComponents.kt deleted file mode 100644 index e6d3fb24..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/CourierDeliveryComponents.kt +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.ui - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationCity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.HintActionButton -import de.gematik.ti.erp.app.utils.compose.HintCard -import de.gematik.ti.erp.app.utils.compose.HintLargeImage -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer24 -import de.gematik.ti.erp.app.utils.compose.Spacer48 -import de.gematik.ti.erp.app.utils.compose.handleIntent -import de.gematik.ti.erp.app.utils.compose.providePhoneIntent -import kotlinx.coroutines.flow.collect - -@Composable -fun CourierDelivery( - navigation: NavController, - viewModel: PharmacySearchViewModel, - taskIds: List, - pharmacyName: String, - telematikId: String, - pharmacyPhone: String -) { - val prescriptions by produceState(initialValue = listOf()) { - taskIds.takeIf { it.isNotEmpty() }?.let { ids -> - viewModel.fetchSelectedOrders(ids).collect { value = it } - } - } - val header = stringResource(id = R.string.pharm_courier_header) - val alpha = if (viewModel.uiState.loading) ContentAlpha.medium else ContentAlpha.high - val context = LocalContext.current - HeaderWithScaffold( - navController = navigation, - viewModel = viewModel, - telematikId = telematikId, - redeemOption = RemoteRedeemOption.Delivery, - header = header, - uiState = viewModel.uiState - ) { - item { - DescriptionHeader(pharmacyName = pharmacyName) - Spacer48() - } - item { - CompositionLocalProvider(LocalContentAlpha provides alpha) { - Text( - text = stringResource(id = R.string.pharm_reserve_delivery_address), - style = MaterialTheme.typography.h6 - ) - } - Spacer16() - } - item { - if (prescriptions.isNotEmpty()) { - DeliveryAddress(alpha) { - Row(modifier = Modifier.padding(16.dp)) { - Icon( - Icons.Default.LocationCity, contentDescription = "", - tint = AppTheme.colors.neutral600 - ) - Spacer16() - Column { - Text( - text = prescriptions[0].patientName, - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral600 - ) - Text( - text = prescriptions[0].address, - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral600 - ) - } - } - } - Spacer16() - } - } - item { - HintCard( - image = { - HintLargeImage( - painterResource(R.drawable.calling_lady), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.pharm_delivery_card_help)) }, - body = { Text(stringResource(R.string.pharm_delivery_card_message)) }, - action = { - HintActionButton(stringResource(R.string.pharm_delivery_card_call)) { - context.handleIntent(providePhoneIntent(pharmacyPhone)) - } - } - ) - Spacer24() - } - item { - PrescriptionHeader(alpha) - Spacer16() - } - items(items = prescriptions) { prescription -> - PrescriptionOrder( - prescription, - toggleContentDescription = "contentDescription", - alpha, - viewModel::toggleOrder, - ) - } - item { - Spacer48() - } - } -} - -@Composable -fun DeliveryAddress(contentAlpha: Float, content: @Composable () -> Unit) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, AppTheme.colors.neutral500), - ) { - CompositionLocalProvider(LocalContentAlpha provides contentAlpha) { - content() - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/EditShippingContactScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/EditShippingContactScreen.kt new file mode 100644 index 00000000..2e7c51fb --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/EditShippingContactScreen.kt @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.util.Patterns +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.max +import androidx.navigation.NavController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.BottomAppBar +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.InputField +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +const val StringLengthLimit = 100 +const val MinPhoneLength = 4 + +@Suppress("LongMethod") +@Composable +fun EditShippingContactScreen( + navController: NavController, + viewModel: PharmacySearchViewModel +) { + val listState = rememberLazyListState() + + val state by viewModel.orderScreenState().collectAsState(PharmacyScreenData.defaultOrderState) + + var contact by remember(state.contact) { mutableStateOf(state.contact) } + + var showBackAlert by remember { mutableStateOf(false) } + + val telephoneOptional by derivedStateOf { + state.orderOption == PharmacyScreenData.OrderOption.ReserveInPharmacy + } + val telephoneError by derivedStateOf { !isPhoneValid(contact.telephoneNumber, telephoneOptional) } + val nameError by derivedStateOf { contact.name.isBlank() } + val line1Error by derivedStateOf { contact.line1.isBlank() } + val codeAndCityError by derivedStateOf { contact.postalCodeAndCity.isBlank() } + val mailError by derivedStateOf { !isMailValid(contact.mail) } + + if (showBackAlert) { + CommonAlertDialog( + header = stringResource(R.string.edit_contact_back_alert_header), + info = stringResource(R.string.edit_contact_back_alert_information), + onCancel = { showBackAlert = false }, + onClickAction = { navController.popBackStack() }, + cancelText = stringResource(R.string.edit_contact_back_alert_change), + actionText = stringResource(R.string.edit_contact_back_alert_action) + ) + } + + AnimatedElevationScaffold( + navigationMode = NavigationBarMode.Back, + bottomBar = { + BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { + Spacer(Modifier.weight(1f)) + Button( + onClick = { + viewModel.onSaveContact(contact) + navController.popBackStack() + }, + enabled = !telephoneError && !mailError && !nameError && !line1Error && !codeAndCityError + ) { + Text(stringResource(R.string.edit_shipping_contact_save)) + } + SpacerSmall() + } + }, + topBarTitle = stringResource(R.string.edit_shipping_contact_top_bar_title), + listState = listState, + onBack = { + if (!telephoneError && !mailError && !nameError && !line1Error && !codeAndCityError) { + viewModel.onSaveContact(contact) + navController.popBackStack() + } else { + showBackAlert = true + } + } + ) { contentPadding -> + val imePadding = WindowInsets.ime.asPaddingValues() + + val focusManager = LocalFocusManager.current + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + contentPadding = PaddingValues( + top = PaddingDefaults.Medium + contentPadding.calculateTopPadding(), + bottom = PaddingDefaults.Medium + max( + imePadding.calculateBottomPadding(), + contentPadding.calculateBottomPadding() + ), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + ) { + item { + Text( + stringResource(R.string.edit_shipping_contact_title_contact), + style = AppTheme.typography.h6 + ) + } + item(key = "InputField_1") { + InputField( + modifier = Modifier + .scrollOnFocus(1, listState) + .fillParentMaxWidth(), + value = contact.telephoneNumber, + onValueChange = { phone -> + contact = contact.copy( + telephoneNumber = phone.trim().take(StringLengthLimit) + ) + }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { + Text( + if (telephoneOptional) { + stringResource(R.string.edit_shipping_contact_phone_optional) + } else { + stringResource(R.string.edit_shipping_contact_phone) + } + ) + }, + isError = telephoneError, + errorText = { Text(stringResource(R.string.edit_shipping_contact_error_phone)) }, + keyBoardType = KeyboardType.Phone + ) + } + item(key = "InputField_2") { + InputField( + modifier = Modifier + .scrollOnFocus(2, listState) + .fillParentMaxWidth(), + value = contact.mail, + onValueChange = { mail -> contact = (contact.copy(mail = mail.take(StringLengthLimit))) }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { Text(stringResource(R.string.edit_shipping_contact_mail)) }, + isError = mailError, + keyBoardType = KeyboardType.Email + ) + } + item { + SpacerLarge() + Text( + stringResource(R.string.edit_shipping_contact_title_address), + style = AppTheme.typography.h6 + ) + } + item(key = "InputField_3") { + InputField( + modifier = Modifier + .scrollOnFocus(4, listState) + .fillParentMaxWidth(), + value = contact.name, + onValueChange = { name -> contact = (contact.copy(name = name.take(StringLengthLimit))) }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { Text(stringResource(R.string.edit_shipping_contact_name)) }, + isError = nameError, + errorText = { Text(stringResource(R.string.edit_shipping_contact_error_name)) } + ) + } + item(key = "InputField_4") { + InputField( + modifier = Modifier + .scrollOnFocus(5, listState) + .fillParentMaxWidth(), + value = contact.line1, + onValueChange = { line1 -> contact = (contact.copy(line1 = line1.take(StringLengthLimit))) }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { Text(stringResource(R.string.edit_shipping_contact_title_line1)) }, + isError = line1Error, + errorText = { Text(stringResource(R.string.edit_shipping_contact_error_line1)) } + ) + } + item(key = "InputField_5") { + InputField( + modifier = Modifier + .scrollOnFocus(6, listState) + .fillParentMaxWidth(), + value = contact.line2, + onValueChange = { line2 -> contact = (contact.copy(line2 = line2.take(StringLengthLimit))) }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { Text(stringResource(R.string.edit_shipping_contact_line2)) }, + isError = false + ) + } + item(key = "InputField_6") { + InputField( + modifier = Modifier + .scrollOnFocus(7, listState) + .fillParentMaxWidth(), + value = contact.postalCodeAndCity, + onValueChange = { postalCodeAndCity -> + contact = (contact.copy(postalCodeAndCity = postalCodeAndCity.take(StringLengthLimit))) + }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { Text(stringResource(R.string.edit_shipping_contact_postal_code_and_city)) }, + isError = codeAndCityError, + errorText = { Text(stringResource(R.string.edit_shipping_contact_error_postal_code_and_city)) } + ) + } + item(key = "InputField_7") { + InputField( + modifier = Modifier + .scrollOnFocus(8, listState) + .fillParentMaxWidth(), + value = contact.deliveryInformation, + onValueChange = { deliveryInformation -> + contact = (contact.copy(deliveryInformation = deliveryInformation.take(StringLengthLimit))) + }, + onSubmit = { focusManager.clearFocus() }, + label = { Text(stringResource(R.string.edit_shipping_contact_delivery_information)) }, + isError = false + ) + } + } + } +} + +fun isMailValid(mail: String): Boolean { + return mail.isEmpty() || (Patterns.EMAIL_ADDRESS.matcher(mail).matches() && mail.length <= StringLengthLimit) +} + +fun isPhoneValid(telephoneNumber: String, optional: Boolean): Boolean { + return if (telephoneNumber.isNotEmpty()) { + telephoneNumber.length in MinPhoneLength..StringLengthLimit && + Patterns.PHONE.matcher(telephoneNumber).matches() + } else optional +} + +private const val LayoutDelay = 330L + +@OptIn(ExperimentalLayoutApi::class) +fun Modifier.scrollOnFocus(to: Int, listState: LazyListState, offset: Int = 0) = composed { + val coroutineScope = rememberCoroutineScope() + val mutex = MutatorMutex() + + var hasFocus by remember { mutableStateOf(false) } + val keyboardVisible = WindowInsets.isImeVisible + + LaunchedEffect(hasFocus, keyboardVisible) { + if (hasFocus && keyboardVisible) { + mutex.mutate { + delay(LayoutDelay) + listState.animateScrollToItem(to, offset) + } + } + } + + onFocusChanged { + if (it.hasFocus) { + hasFocus = true + coroutineScope.launch { + mutex.mutate(MutatePriority.UserInput) { + delay(LayoutDelay) + listState.animateScrollToItem(to, offset) + } + } + } else { + hasFocus = false + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +fun Modifier.scrollOnFocus() = composed { + val coroutineScope = rememberCoroutineScope() + val mutex = MutatorMutex() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + var hasFocus by remember { mutableStateOf(false) } + val keyboardVisible = WindowInsets.isImeVisible + + LaunchedEffect(hasFocus, keyboardVisible) { + if (hasFocus && keyboardVisible) { + mutex.mutate { + delay(LayoutDelay) + bringIntoViewRequester.bringIntoView() + } + } + } + + bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { + if (it.hasFocus) { + hasFocus = true + coroutineScope.launch { + mutex.mutate(MutatePriority.UserInput) { + delay(LayoutDelay) + bringIntoViewRequester.bringIntoView() + } + } + } else { + hasFocus = false + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MailDeliveryComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MailDeliveryComponents.kt deleted file mode 100644 index f61d8430..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MailDeliveryComponents.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationCity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer24 -import de.gematik.ti.erp.app.utils.compose.Spacer48 -import kotlinx.coroutines.flow.collect - -@Composable -fun MailDelivery( - navigation: NavController, - viewModel: PharmacySearchViewModel, - taskIds: List, - pharmacyName: String, - telematikId: String -) { - val prescriptions by produceState(initialValue = listOf()) { - taskIds.takeIf { it.isNotEmpty() }?.let { ids -> - viewModel.fetchSelectedOrders(ids).collect { value = it } - } - } - val header = stringResource(id = R.string.pharm_mail_header) - val alpha = if (viewModel.uiState.loading) ContentAlpha.medium else ContentAlpha.high - HeaderWithScaffold( - navController = navigation, - viewModel = viewModel, - telematikId = telematikId, - redeemOption = RemoteRedeemOption.Shipment, - header = header, - uiState = viewModel.uiState - ) { - item { - DescriptionHeader(pharmacyName = pharmacyName) - Spacer48() - } - item { - CompositionLocalProvider(LocalContentAlpha provides alpha) { - Text( - text = stringResource(id = R.string.pharm_reserve_delivery_address), - style = MaterialTheme.typography.h6 - ) - } - Spacer16() - } - item { - if (prescriptions.isNotEmpty()) { - DeliveryAddress(alpha) { - Row(modifier = Modifier.padding(16.dp)) { - Icon( - Icons.Default.LocationCity, - contentDescription = "", - tint = AppTheme.colors.neutral600 - ) - Spacer16() - Column { - Text( - text = prescriptions[0].patientName, - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral600 - ) - Text( - text = prescriptions[0].address, - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral600 - ) - } - } - } - Spacer24() - } - } - item { - PrescriptionHeader(alpha) - Spacer16() - } - items(items = prescriptions) { prescription -> - PrescriptionOrder( - prescription, - toggleContentDescription = "contentDescription", - alpha, - viewModel::toggleOrder, - ) - } - item { - Spacer48() - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MapsOverview.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MapsOverview.kt new file mode 100644 index 00000000..bb03f46d --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MapsOverview.kt @@ -0,0 +1,824 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Point +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SwipeableDefaults +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NearMe +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.MyLocation +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReusableContent +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.LocationSource +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.maps.android.SphericalUtil +import com.google.maps.android.compose.CameraPositionState +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberMarkerState +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.fhir.model.DeliveryPharmacyService +import de.gematik.ti.erp.app.fhir.model.Location +import de.gematik.ti.erp.app.fhir.model.OnlinePharmacyService +import de.gematik.ti.erp.app.fhir.model.PickUpPharmacyService +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.prescription.ui.GenerellErrorState +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall +import de.gematik.ti.erp.app.utils.compose.SecondaryButton +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +private val Berlin = LatLng(52.51947562977698, 13.404335795642881) +private const val DefaultZoomLevel = 12.2f +private const val MyLocationZoomLevel = 15f + +private fun Location.toLatLng() = + LatLng(latitude, longitude) + +private fun PharmacyUseCaseData.LocationMode.Enabled.toLatLng() = + location.toLatLng() + +@Composable +fun MapsOverviewSmall( + modifier: Modifier, + onClick: () -> Unit +) { + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(Berlin, DefaultZoomLevel) + } + Box(modifier) { + GoogleMap( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)), + cameraPositionState = cameraPositionState, + uiSettings = remember { + MapUiSettings( + compassEnabled = false, + indoorLevelPickerEnabled = false, + mapToolbarEnabled = false, + myLocationButtonEnabled = false, + rotationGesturesEnabled = false, + scrollGesturesEnabled = false, + scrollGesturesEnabledDuringRotateOrZoom = false, + tiltGesturesEnabled = false, + zoomControlsEnabled = false, + zoomGesturesEnabled = false + ) + } + ) + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + ) + } +} + +@Stable +private sealed interface SheetContentState { + + @Stable + data class PharmacySelected(val pharmacy: PharmacyUseCaseData.Pharmacy) : SheetContentState + + @Stable + object FilterSelected : SheetContentState +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun MapsOverview( + pharmacySearchController: PharmacySearchController, + hasRedeemableTasks: Boolean, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy, PharmacyScreenData.OrderOption?) -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + val scaffoldState = rememberScaffoldState() + + val cameraPositionState = rememberCameraPositionState { + val latLng = + (pharmacySearchController.searchState.locationMode as? PharmacyUseCaseData.LocationMode.Enabled)?.toLatLng() + ?: Berlin + position = CameraPosition.fromLatLngZoom(latLng, DefaultZoomLevel) + } + + var pharmacies by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(Unit) { + pharmacySearchController + .pharmacyMapsFlow + .collect { result -> + when (result) { + is PharmacySearchController.State.Pharmacies -> + pharmacies = result.pharmacies + + is GenerellErrorState -> + mapsErrorMessage(context, result)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } + } + + var showSearchButton by remember { mutableStateOf(false) } + CameraAnimation( + cameraPositionState = cameraPositionState, + pharmacySearchController = pharmacySearchController, + pharmacies = pharmacies, + onShowSearchButton = { + showSearchButton = true + } + ) + + val scope = rememberCoroutineScope() + + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden, + skipHalfExpanded = true, + confirmStateChange = { it != ModalBottomSheetValue.HalfExpanded } + ) + var sheetContentState by remember { mutableStateOf(SheetContentState.FilterSelected) } + + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + when (sheetContentState) { + SheetContentState.FilterSelected -> + FilterSheetContent( + modifier = Modifier.navigationBarsPadding(), + filter = pharmacySearchController.searchState.filter, + onClickChip = { filter -> + scope.launch { + val l = cameraPositionState.position.target + val radius = SphericalUtil.computeDistanceBetween( + cameraPositionState.projection?.visibleRegion?.latLngBounds?.northeast, + cameraPositionState.projection?.visibleRegion?.latLngBounds?.southwest + ) / 2.0 + pharmacySearchController.search( + pharmacySearchController.searchState.name, + filter.copy(nearBy = false), + Location(latitude = l.latitude, longitude = l.longitude), + radius + ) + } + }, + onClickClose = { scope.launch { sheetState.hide() } }, + showNearByFilter = false + ) + + is SheetContentState.PharmacySelected -> + PharmacySheetContent( + pharmacy = (sheetContentState as SheetContentState.PharmacySelected).pharmacy, + hasRedeemableTasks = hasRedeemableTasks, + onClickDetails = onSelectPharmacy + ) + } + }, + sheetShape = remember { RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) } + ) { + ScaffoldWithMap( + scaffoldState = scaffoldState, + cameraPositionState = cameraPositionState, + pharmacySearchController = pharmacySearchController, + pharmacies = pharmacies, + showSearchButton = showSearchButton, + onShowSearchButton = { + showSearchButton = it + }, + onShowBottomSheet = { + sheetContentState = it + scope.launch { sheetState.animateTo(ModalBottomSheetValue.Expanded) } + }, + onBack = onBack + ) + } +} + +@Composable +private fun ScaffoldWithMap( + scaffoldState: ScaffoldState, + cameraPositionState: CameraPositionState, + pharmacySearchController: PharmacySearchController, + pharmacies: List, + showSearchButton: Boolean, + onShowSearchButton: (Boolean) -> Unit, + onShowBottomSheet: (SheetContentState) -> Unit, + onBack: () -> Unit +) { + val scope = rememberCoroutineScope() + + var showNoLocationDialog by remember { mutableStateOf(false) } + + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.any { it }) { + scope.launch { + val radius = SphericalUtil.computeDistanceBetween( + cameraPositionState.projection?.visibleRegion?.latLngBounds?.northeast, + cameraPositionState.projection?.visibleRegion?.latLngBounds?.southwest + ) / 2.0 + + pharmacySearchController.search( + pharmacySearchController.searchState.name, + pharmacySearchController.searchState.filter.copy(nearBy = true), + radiusInMeter = radius + ) + } + } else { + showNoLocationDialog = true + } + } + + if (showNoLocationDialog) { + NoLocationDialog( + onAccept = { + showNoLocationDialog = false + } + ) + } + + var showNoLocationServicesDialog by remember { mutableStateOf(false) } + if (showNoLocationServicesDialog) { + NoLocationServicesDialog( + onClose = { + showNoLocationServicesDialog = false + } + ) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + snackbarHost = { + SnackbarHost(it, modifier = Modifier.systemBarsPadding()) + } + ) { innerPadding -> + Box { + FullscreenMap( + cameraPositionState = cameraPositionState, + innerPadding = innerPadding, + pharmacies = pharmacies, + onClickMarker = { + onShowBottomSheet(SheetContentState.PharmacySelected(it)) + } + ) + + MapOverlay( + showSearchButton = showSearchButton, + isLoading = pharmacySearchController.isLoading, + onSearch = { + if (!pharmacySearchController.isLoading) { + scope.launch { + onShowSearchButton(false) + + val radius = SphericalUtil.computeDistanceBetween( + cameraPositionState.projection?.visibleRegion?.latLngBounds?.northeast, + cameraPositionState.projection?.visibleRegion?.latLngBounds?.southwest + ) / 2.0 + + if (it) { + // search here button + val l = cameraPositionState.position.target + pharmacySearchController.search( + pharmacySearchController.searchState.name, + pharmacySearchController.searchState.filter.copy(nearBy = false), + Location(latitude = l.latitude, longitude = l.longitude), + radius + ) + } else { + // find me button + pharmacySearchController.search( + pharmacySearchController.searchState.name, + pharmacySearchController.searchState.filter.copy(nearBy = true), + radiusInMeter = radius + ) + }.also { + when (it) { + PharmacySearchController.SearchQueryResult.NoLocationPermission -> { + locationPermissionLauncher.launch(locationPermissions) + } + PharmacySearchController.SearchQueryResult.NoLocationFound -> { + showNoLocationDialog = true + onShowSearchButton(true) + } + PharmacySearchController.SearchQueryResult.NoLocationServicesEnabled -> { + showNoLocationServicesDialog = true + onShowSearchButton(true) + } + + else -> { + } + } + } + } + } + }, + onClickFilter = { + onShowBottomSheet(SheetContentState.FilterSelected) + }, + onBack = onBack + ) + } + } +} + +@Composable +private fun CameraAnimation( + cameraPositionState: CameraPositionState, + pharmacySearchController: PharmacySearchController, + pharmacies: List, + onShowSearchButton: () -> Unit +) { + var lastMarkerCenter by remember { mutableStateOf(Berlin) } + val isMoving by derivedStateOf { cameraPositionState.isMoving } + + val moveDistance = with(LocalDensity.current) { 24.dp.roundToPx() } + LaunchedEffect(isMoving) { + if (!isMoving) { + cameraPositionState.projection?.let { projection -> + val latLng = + ( + pharmacySearchController.searchState.locationMode as? + PharmacyUseCaseData.LocationMode.Enabled + )?.toLatLng() + ?: lastMarkerCenter + val d = SphericalUtil.computeDistanceBetween(cameraPositionState.position.target, latLng) + val a = projection.fromScreenLocation(Point(0, 0)) + val b = projection.fromScreenLocation(Point(moveDistance, 0)) + if (d >= SphericalUtil.computeDistanceBetween(a, b)) { + onShowSearchButton() + } + } + } + } + + val padding = with(LocalDensity.current) { PaddingDefaults.XXLarge.roundToPx() } + LaunchedEffect(pharmacies) { + if (pharmacySearchController.searchState.filter.nearBy) { + val latLng = + (pharmacySearchController.searchState.locationMode as? PharmacyUseCaseData.LocationMode.Enabled) + ?.toLatLng() + ?: lastMarkerCenter + + cameraPositionState.animate( + CameraUpdateFactory.newLatLngZoom(latLng, MyLocationZoomLevel), + durationMs = 730 + ) + } else if (pharmacies.isNotEmpty()) { + val bounds = LatLngBounds.builder().apply { + pharmacies.forEach { + include(it.location!!.toLatLng()) + } + }.build() + cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, padding), durationMs = 730) + lastMarkerCenter = bounds.center + } + } +} + +@Composable +private fun FullscreenMap( + cameraPositionState: CameraPositionState, + innerPadding: PaddingValues, + pharmacies: List, + onClickMarker: (PharmacyUseCaseData.Pharmacy) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + GoogleMap( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + locationSource = remember { locationSourceOnce(context, scope) }, + cameraPositionState = cameraPositionState, + uiSettings = remember { + MapUiSettings( + compassEnabled = false, + indoorLevelPickerEnabled = false, + mapToolbarEnabled = false, + myLocationButtonEnabled = false, + rotationGesturesEnabled = false, + scrollGesturesEnabled = true, + scrollGesturesEnabledDuringRotateOrZoom = true, + tiltGesturesEnabled = false, + zoomControlsEnabled = false, + zoomGesturesEnabled = true + ) + }, + properties = remember { + MapProperties( + isMyLocationEnabled = true + ) + }, + contentPadding = WindowInsets.Companion.systemBars.asPaddingValues(), + content = { + ReusableContent(pharmacies) { + MapsContent( + pharmacyMapsResult = pharmacies, + onClick = onClickMarker + ) + } + } + ) +} + +@Composable +private fun MapsContent( + pharmacyMapsResult: List, + onClick: (PharmacyUseCaseData.Pharmacy) -> Unit +) { + val markerIcon = remember { BitmapDescriptorFactory.fromResource(R.drawable.maps_marker) } + pharmacyMapsResult.mapNotNull { pharmacy -> + pharmacy.location?.let { location -> + val latLng = LatLng(location.latitude, location.longitude) + Marker( + state = rememberMarkerState( + position = latLng + ), + icon = markerIcon, + onClick = { + onClick(pharmacy) + false + } + ) + } + } +} + +@Composable +private fun BoxScope.MapOverlay( + showSearchButton: Boolean, + isLoading: Boolean, + onSearch: (Boolean) -> Unit, + onClickFilter: () -> Unit, + onBack: () -> Unit +) { + Row( + Modifier + .fillMaxWidth() + .padding(start = PaddingDefaults.Small, end = PaddingDefaults.Medium) + .systemBarsPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + onClick = onBack + ) { + Box( + Modifier + .size(32.dp) + .shadow(2.dp, CircleShape) + .background(AppTheme.colors.neutral100, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null, + tint = AppTheme.colors.primary600, + modifier = Modifier.size(24.dp) + ) + } + } + + IconButton( + modifier = Modifier + .size(48.dp) + .shadow(2.dp, CircleShape) + .border(1.dp, AppTheme.colors.neutral300, CircleShape) + .background(AppTheme.colors.neutral100, CircleShape), + onClick = onClickFilter + ) { + Icon( + Icons.Outlined.Tune, + contentDescription = null, + tint = AppTheme.colors.primary600, + modifier = Modifier.size(24.dp) + ) + } + } + + Column( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .systemBarsPadding() + ) { + IconButton( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = PaddingDefaults.Medium) + .padding(bottom = 80.dp) + .size(56.dp) + .shadow(2.dp, CircleShape) + .border(1.dp, AppTheme.colors.neutral300, CircleShape) + .background(AppTheme.colors.neutral100, CircleShape), + onClick = { + onSearch(false) + } + ) { + Crossfade( + targetState = isLoading + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (it) { + CircularProgressIndicator( + strokeWidth = 3.dp + ) + } else { + Icon( + Icons.Rounded.MyLocation, + contentDescription = null, + tint = AppTheme.colors.primary600, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.CenterHorizontally), + visible = showSearchButton, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top) + slideInVertically(), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top) + slideOutVertically() + ) { + PrimaryButtonSmall( + onClick = { + onSearch(true) + }, + modifier = Modifier + .padding(bottom = PaddingDefaults.Large) + ) { + Icon(Icons.Rounded.Search, null) + SpacerSmall() + Text(stringResource(R.string.pharmacy_maps_search_here_button)) + } + } + } +} + +fun locationSourceOnce(context: Context, coroutineScope: CoroutineScope) = object : LocationSource { + private var currentListener = MutableStateFlow(null) + + init { + coroutineScope.launch { + currentListener.collectLatest { listener -> + if (listener != null) { + queryNativeLocation(context)?.let { + listener.onLocationChanged(it) + } + } + } + } + } + + override fun activate(listener: LocationSource.OnLocationChangedListener) { + currentListener.value = listener + } + + override fun deactivate() { + currentListener.value = null + } +} + +@Composable +@ExperimentalMaterialApi +private fun rememberModalBottomSheetState( + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + skipHalfExpanded: Boolean, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } +): ModalBottomSheetState { + return remember( + initialValue, + animationSpec, + skipHalfExpanded, + confirmStateChange + ) { + ModalBottomSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + } +} + +@Composable +private fun PharmacySheetContent( + pharmacy: PharmacyUseCaseData.Pharmacy?, + hasRedeemableTasks: Boolean, + onClickDetails: (PharmacyUseCaseData.Pharmacy, PharmacyScreenData.OrderOption?) -> Unit +) { + val context = LocalContext.current + val hasGoogleMaps = remember { hasGoogleMaps(context) } + + Column( + Modifier + .navigationBarsPadding() + .padding(PaddingDefaults.Medium) + ) { + Row( + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + pharmacy?.let { + Column( + Modifier + .weight(1f) + .clickable( + role = Role.Button, + onClick = { + onClickDetails(pharmacy, null) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + ) { + Text(pharmacy.name, style = AppTheme.typography.h6) + Text( + text = stringResource(R.string.pharmacy_maps_contacts), + style = AppTheme.typography.subtitle2, + color = AppTheme.colors.primary600 + ) + } + if (hasGoogleMaps) { + OutlinedButton( + onClick = { pharmacy.location?.let { navigateWithGoogleMaps(context, it) } }, + border = BorderStroke(1.dp, AppTheme.colors.neutral300), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = AppTheme.colors.neutral025) + ) { + Icon(Icons.Outlined.NearMe, null) + } + } + } + } + + SpacerLarge() + + pharmacy?.let { + Column(verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium)) { + if (remember { pharmacy.provides.any { it is PickUpPharmacyService } }) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { onClickDetails(pharmacy, PharmacyScreenData.OrderOption.ReserveInPharmacy) }, + enabled = hasRedeemableTasks + ) { + Text(text = stringResource(R.string.pharm_detail_preorder_prescription)) + } + } + + if (remember { pharmacy.provides.any { it is DeliveryPharmacyService } }) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { onClickDetails(pharmacy, PharmacyScreenData.OrderOption.CourierDelivery) }, + enabled = hasRedeemableTasks + ) { + Text(text = stringResource(R.string.pharm_detail_messanger_delivered)) + } + } + if (remember { pharmacy.provides.any { it is OnlinePharmacyService } }) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTag.PharmacySearch.OrderOptions.OnlineDeliveryOptionButton), + onClick = { onClickDetails(pharmacy, PharmacyScreenData.OrderOption.MailDelivery) }, + enabled = hasRedeemableTasks + ) { + Text(text = stringResource(R.string.pharm_detail_mail_delivered)) + } + } + } + } + } +} + +private fun hasGoogleMaps(context: Context): Boolean = + try { + context + .packageManager + .getPackageInfo("com.google.android.apps.maps", PackageManager.GET_ACTIVITIES) + true + } catch (_: Exception) { + false + } + +private fun navigateWithGoogleMaps( + context: Context, + location: Location +) { + val gmmIntentUri = + Uri.parse("https://www.google.com/maps/dir/?api=1&destination=${location.latitude},${location.longitude}") + val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) + mapIntent.setPackage("com.google.android.apps.maps") + context.startActivity(mapIntent) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MapsSnackbar.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MapsSnackbar.kt new file mode 100644 index 00000000..de47a0f5 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/MapsSnackbar.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.content.Context +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.ui.GenerellErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState + +fun mapsErrorMessage(context: Context, deleteState: PrescriptionServiceErrorState): String? = + when (deleteState) { + GenerellErrorState.NetworkNotAvailable -> + context.getString(R.string.error_message_network_not_available) + else -> null + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyDetailsScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyDetailsScreenComponents.kt index 08da39a1..02e83d89 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyDetailsScreenComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyDetailsScreenComponents.kt @@ -38,11 +38,13 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Map import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,40 +55,48 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material.IconButton +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.pharmacy.repository.model.DeliveryPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.Location -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningHours -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningTime -import de.gematik.ti.erp.app.pharmacy.repository.model.RoleCode -import de.gematik.ti.erp.app.pharmacy.repository.model.isOpenToday +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.fhir.model.DeliveryPharmacyService +import de.gematik.ti.erp.app.fhir.model.Location +import de.gematik.ti.erp.app.fhir.model.OnlinePharmacyService +import de.gematik.ti.erp.app.fhir.model.OpeningHours +import de.gematik.ti.erp.app.fhir.model.PickUpPharmacyService +import de.gematik.ti.erp.app.fhir.model.isOpenToday +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyNavigationScreens +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.HintCard import de.gematik.ti.erp.app.utils.compose.HintCardDefaults import de.gematik.ti.erp.app.utils.compose.HintSmallImage import de.gematik.ti.erp.app.utils.compose.HintTextActionButton import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer24 import de.gematik.ti.erp.app.utils.compose.Spacer4 import de.gematik.ti.erp.app.utils.compose.Spacer8 +import de.gematik.ti.erp.app.utils.compose.SpacerLarge import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.canHandleIntent +import de.gematik.ti.erp.app.utils.compose.createToastShort import de.gematik.ti.erp.app.utils.compose.handleIntent import de.gematik.ti.erp.app.utils.compose.provideEmailIntent import de.gematik.ti.erp.app.utils.compose.providePhoneIntent -import java.time.DayOfWeek -import java.time.Duration -import java.time.LocalTime import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -96,128 +106,211 @@ import java.util.Locale @Composable fun PharmacyDetailsScreen( navController: NavController, - pharmacy: PharmacyUseCaseData.Pharmacy, - showRedeemOptions: Boolean + viewModel: PharmacySearchViewModel, + hasRedeemableTasks: Boolean, + onClickFavoriteStar: (PharmacyUseCaseData.Pharmacy, Boolean) -> Unit ) { val context = LocalContext.current - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = stringResource(R.string.pharmacy_detail_title), - onBack = { navController.popBackStack() } - ) - } + + val state by viewModel.detailScreenState().collectAsState(null) + val pharmacy = state?.selectedPharmacy + + val scrollState = rememberScrollState() + + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.PharmacySearch.OrderOptions.Screen), + topBarTitle = stringResource(R.string.pharmacy_detail_title), + elevated = scrollState.value > 0, + actions = { + state?.let { state -> + FavoriteStarButton(state.markedAsFavorite) { + onClickFavoriteStar(state.selectedPharmacy, it) + } + } + }, + navigationMode = NavigationBarMode.Back, + onBack = { navController.popBackStack() } ) { Column( modifier = Modifier .padding(horizontal = PaddingDefaults.Medium) - .verticalScroll(rememberScrollState()) - .padding( - rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) - ) + .verticalScroll(scrollState) + .navigationBarsPadding() + .testTag(TestTag.PharmacySearch.OrderOptions.Content) ) { - SpacerMedium() + if (pharmacy != null) { + SpacerMedium() - if (pharmacy.ready) { - ReadyFlag() - SpacerSmall() - } - - Text( - text = pharmacy.name, - style = MaterialTheme.typography.h5 - ) - SpacerSmall() - Row( - modifier = Modifier - .clickable { - pharmacy.location?.let { - launchMaps(context, it, pharmacy.name) - } - }, - verticalAlignment = Alignment.CenterVertically, - ) { Text( - text = pharmacy.removeLineBreaksFromAddress(), - style = MaterialTheme.typography.subtitle2, - color = MaterialTheme.colors.secondary, + text = pharmacy.name, + style = AppTheme.typography.h5 ) - Spacer8() - Icon( - imageVector = Icons.Default.Map, - contentDescription = "", - tint = MaterialTheme.colors.secondary - ) - } - - Spacer24() - - if (pharmacy.ready) { - if (showRedeemOptions) { - OrderOptions(navController, pharmacy) - Spacer16() - HintCard( - properties = HintCardDefaults.properties( - backgroundColor = AppTheme.colors.primary100, - border = BorderStroke(0.0.dp, AppTheme.colors.neutral300), - elevation = 0.dp - ), - image = { - HintSmallImage( - painterResource(R.drawable.ic_info), - innerPadding = it - ) + SpacerSmall() + Row( + modifier = Modifier + .clickable { + pharmacy.location?.let { + launchMaps(context, it, pharmacy.name) + } }, - title = { Text(stringResource(R.string.pharm_detail_hint_header)) }, - body = { Text(stringResource(R.string.pharm_detail_hint)) } + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pharmacy.removeLineBreaksFromAddress(), + style = AppTheme.typography.subtitle2, + color = MaterialTheme.colors.secondary + ) + SpacerSmall() + Icon( + imageVector = Icons.Default.Map, + contentDescription = "", + tint = MaterialTheme.colors.secondary ) } - } else { - HintCard( - properties = HintCardDefaults.properties( - backgroundColor = AppTheme.colors.red100, - contentColor = AppTheme.colors.neutral999, - border = BorderStroke(0.0.dp, AppTheme.colors.neutral300), - elevation = 0.dp - ), - image = { - HintSmallImage( - painterResource(R.drawable.medical_hand_out_circle_red), - innerPadding = it + + SpacerLarge() + + if (pharmacy.ready) { + OrderOptions(hasRedeemableTasks, pharmacy) { + viewModel.onSelectOrderOption(it) + navController.navigate(PharmacyNavigationScreens.OrderPrescription.path()) + } + + if (!hasRedeemableTasks) { + Text( + text = stringResource(R.string.pharmacy_detail_no_redeemable_prescription_info), + style = AppTheme.typography.caption1l, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center ) - }, - title = { Text(stringResource(R.string.pharmacy_detail_not_ready_header)) }, - body = { Text(stringResource(R.string.pharmacy_detail_not_ready_info)) } - ) + } + SpacerMedium() + ReadyHintCard() + } else { + NotReadyHintCard() + } + Spacer(modifier = Modifier.size(PaddingDefaults.XXLarge)) + + PharmacyInfo(pharmacy) + + SpacerMedium() } - Spacer(modifier = Modifier.size(PaddingDefaults.XXLarge)) + } + } +} + +@Composable +private fun ReadyHintCard() { + HintCard( + properties = HintCardDefaults.properties( + backgroundColor = AppTheme.colors.primary100, + border = BorderStroke(0.0.dp, AppTheme.colors.neutral300), + elevation = 0.dp + ), + image = { + HintSmallImage( + painterResource(R.drawable.ic_info), + innerPadding = it + ) + }, + title = { Text(stringResource(R.string.pharm_detail_hint_header)) }, + body = { Text(stringResource(R.string.pharm_detail_hint)) } + ) +} - PharmacyInfo(pharmacy) +@Composable +private fun NotReadyHintCard() { + HintCard( + properties = HintCardDefaults.properties( + backgroundColor = AppTheme.colors.red100, + contentColor = AppTheme.colors.neutral999, + border = BorderStroke(0.0.dp, AppTheme.colors.neutral300), + elevation = 0.dp + ), + image = { + HintSmallImage( + painterResource(R.drawable.medical_hand_out_circle_red), + innerPadding = it + ) + }, + title = { Text(stringResource(R.string.pharmacy_detail_not_ready_header)) }, + body = { Text(stringResource(R.string.pharmacy_detail_not_ready_info)) } + ) +} - SpacerMedium() +@Composable +private fun FavoriteStarButton( + isMarkedAsFavorite: Boolean, + modifier: Modifier = Modifier, + onChange: (Boolean) -> Unit +) { + var isMarked by remember { mutableStateOf(isMarkedAsFavorite) } + + val color = if (isMarked) { + AppTheme.colors.yellow500 + } else { + AppTheme.colors.primary600 + } + + val icon = if (isMarked) { + Icons.Rounded.Star + } else { + Icons.Rounded.StarBorder + } + + val addedText = stringResource(R.string.pharmacy_detals_added_to_favorites) + val removedText = stringResource(R.string.pharmacy_detalls_removed_from_favorites) + + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isMarked = !isMarked + onChange(isMarked) + createToastShort( + context, + if (isMarked) { + addedText + } else { + removedText + } + ) } + ) { + Icon( + icon, + contentDescription = null, + modifier = modifier + .padding(PaddingDefaults.Medium) + .size(24.dp), + tint = color + ) } } @Composable -private fun OrderOptions(navController: NavController, pharmacy: PharmacyUseCaseData.Pharmacy) { - if (pharmacy.roleCode.any { it == RoleCode.OUT_PHARM }) { +private fun OrderOptions( + hasRedeemableTasks: Boolean, + pharmacy: PharmacyUseCaseData.Pharmacy, + onClickOrder: (PharmacyScreenData.OrderOption) -> Unit +) { + if (pharmacy.provides.any { it is PickUpPharmacyService }) { Button( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), - onClick = { navController.navigate("reserveInPharmacy") }, + .padding(bottom = PaddingDefaults.Medium) + .testTag(TestTag.PharmacySearch.OrderOptions.PickUpOptionButton), + enabled = hasRedeemableTasks, + onClick = { onClickOrder(PharmacyScreenData.OrderOption.ReserveInPharmacy) }, colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.secondary ), shape = RoundedCornerShape(8.dp) ) { Text( - modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + modifier = Modifier.padding(vertical = PaddingDefaults.Small), text = stringResource(id = R.string.pharm_detail_preorder_prescription).uppercase( Locale.getDefault() ) @@ -229,8 +322,10 @@ private fun OrderOptions(navController: NavController, pharmacy: PharmacyUseCase Button( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), - onClick = { navController.navigate("courierDelivery") }, + .padding(bottom = PaddingDefaults.Medium) + .testTag(TestTag.PharmacySearch.OrderOptions.CourierDeliveryOptionButton), + enabled = hasRedeemableTasks, + onClick = { onClickOrder(PharmacyScreenData.OrderOption.CourierDelivery) }, colors = ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral050, contentColor = AppTheme.colors.primary700 @@ -238,27 +333,29 @@ private fun OrderOptions(navController: NavController, pharmacy: PharmacyUseCase shape = RoundedCornerShape(8.dp) ) { Text( - modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + modifier = Modifier.padding(vertical = PaddingDefaults.Small), text = stringResource(id = R.string.pharm_detail_messanger_delivered).uppercase( Locale.getDefault() ) ) } } - if (pharmacy.roleCode.any { it == RoleCode.MOBL }) { + if (pharmacy.provides.any { it is OnlinePharmacyService }) { Button( shape = RoundedCornerShape(8.dp), modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), - onClick = { navController.navigate("mailDelivery") }, + .padding(bottom = PaddingDefaults.Medium) + .testTag(TestTag.PharmacySearch.OrderOptions.OnlineDeliveryOptionButton), + enabled = hasRedeemableTasks, + onClick = { onClickOrder(PharmacyScreenData.OrderOption.MailDelivery) }, colors = ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral050, contentColor = AppTheme.colors.primary700 ) ) { Text( - modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + modifier = Modifier.padding(vertical = PaddingDefaults.Small), text = stringResource(id = R.string.pharm_detail_mail_delivered).uppercase(Locale.getDefault()) ) } @@ -276,7 +373,7 @@ private fun PharmacyInfo(pharmacy: PharmacyUseCaseData.Pharmacy) { } Text( text = stringResource(id = R.string.legal_notice_contact_header), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) SpacerMedium() val context = LocalContext.current @@ -300,7 +397,7 @@ private fun PharmacyOpeningHours(openingHours: OpeningHours) { Column { Text( text = stringResource(id = R.string.pharm_detail_opening_hours), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) SpacerMedium() @@ -335,12 +432,14 @@ private fun PharmacyOpeningHours(openingHours: OpeningHours) { color = AppTheme.colors.green600, fontWeight = FontWeight.Medium ) + isOpenToday -> Text( text = text, color = AppTheme.colors.neutral600, fontWeight = FontWeight.Medium ) + else -> Text( text = text, @@ -355,46 +454,6 @@ private fun PharmacyOpeningHours(openingHours: OpeningHours) { } } -@Preview -@Composable -private fun PharmacyOpeningHoursPreview() { - val now = OffsetDateTime.now() - AppTheme { - PharmacyOpeningHours( - OpeningHours( - mapOf( - DayOfWeek.MONDAY to listOf( - OpeningTime( - LocalTime.of(12, 1), - LocalTime.of(14, 1) - ) - ), - DayOfWeek.TUESDAY to listOf( - OpeningTime( - LocalTime.of(8, 0), - LocalTime.of(18, 0) - ) - ), - DayOfWeek.WEDNESDAY to listOf( - OpeningTime(LocalTime.of(8, 0), LocalTime.of(12, 0)), - OpeningTime(LocalTime.of(14, 0), LocalTime.of(18, 0)), - ), - now.dayOfWeek to listOf( - OpeningTime( - now.toLocalTime() - Duration.ofHours(2), - now.toLocalTime() + Duration.ofHours(2) - ), - OpeningTime( - now.toLocalTime() + Duration.ofHours(4), - now.toLocalTime() + Duration.ofHours(6) - ), - ) - ) - ) - ) - } -} - @Composable private fun PharmacyPhoneContact(context: Context, phone: String) { Label( @@ -492,13 +551,13 @@ private fun Label( ) { Text( text = text, - style = MaterialTheme.typography.body1, + style = AppTheme.typography.body1, color = AppTheme.colors.primary600 ) Spacer4() Text( text = label, - style = AppTheme.typography.body2l, + style = AppTheme.typography.body2l ) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyOrderScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyOrderScreen.kt new file mode 100644 index 00000000..558e3682 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyOrderScreen.kt @@ -0,0 +1,585 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.outlined.Mail +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material.icons.rounded.AddAPhoto +import androidx.compose.material.icons.rounded.PersonOutline +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.mainscreen.ui.ssoStatusColor +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyNavigationScreens +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.prescriptionId +import de.gematik.ti.erp.app.prescriptionIds +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.ui.Avatar +import de.gematik.ti.erp.app.profiles.ui.profileColor +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.PrimaryButton +import de.gematik.ti.erp.app.utils.compose.SecondaryButton +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import de.gematik.ti.erp.app.utils.dateTimeShortText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +@Suppress("LongMethod") +@Composable +fun PharmacyOrderScreen( + navController: NavController, + viewModel: PharmacySearchViewModel, + onSuccessfullyOrdered: (PharmacyScreenData.OrderOption) -> Unit +) { + val listState = rememberLazyListState() + val scaffoldState = rememberScaffoldState() + + val state by produceState(PharmacyScreenData.defaultOrderState) { + viewModel.orderScreenState().collect { + value = it + } + } + + val shippingContactCompleted by derivedStateOf { + state.prescriptions.isNotEmpty() && + if (state.orderOption == PharmacyScreenData.OrderOption.ReserveInPharmacy) { + !state.contact.addressIsMissing() + } else { + !state.contact.phoneOrAddressMissing() + } + } + + val reserveTitle = stringResource(R.string.pharmacy_order_top_bar_title_order) + val orderTitle = stringResource(R.string.pharmacy_order_top_bar_title_order) + val reserveButtonText = stringResource(R.string.pharmacy_order_button_text_reserve) + val orderButtonText = stringResource(R.string.pharmacy_order_button_text_order) + + val topBarTitle = remember(state) { + when (state.orderOption) { + PharmacyScreenData.OrderOption.ReserveInPharmacy -> reserveTitle + else -> orderTitle + } + } + val buttonText = remember(state) { + when (state.orderOption) { + PharmacyScreenData.OrderOption.ReserveInPharmacy -> reserveButtonText + else -> orderButtonText + } + } + + var uploadInProgress by remember { mutableStateOf(false) } + + val coroutineScope = rememberCoroutineScope() + + val redeemController = rememberRedeemPrescriptionsController() + val context = LocalContext.current + + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.PharmacySearch.OrderSummary.Screen), + scaffoldState = scaffoldState, + navigationMode = NavigationBarMode.Back, + bottomBar = { + Surface( + color = MaterialTheme.colors.surface, + elevation = 12.dp + ) { + Column( + Modifier + .navigationBarsPadding() + .padding(horizontal = PaddingDefaults.Medium, vertical = 12.dp) + ) { + Text( + stringResource(R.string.pharmacy_order_bottom_information), + textAlign = TextAlign.Center, + style = AppTheme.typography.caption1l, + modifier = Modifier.fillMaxWidth() + ) + SpacerSmall() + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTag.PharmacySearch.OrderSummary.SendOrderButton), + enabled = shippingContactCompleted && state.anySelected() && !uploadInProgress, + onClick = { + uploadInProgress = true + coroutineScope.launch { + try { + val redeemState = redeemController + .orderPrescriptions( + profileId = state.activeProfile.id, + orderId = UUID.randomUUID(), + prescriptions = state.prescriptions.filter { it.second }.map { it.first }, + redeemOption = state.orderOption, + pharmacy = state.selectedPharmacy, + contact = state.contact + ) + when (redeemState) { + is PrescriptionServiceErrorState -> { + redeemErrorMessage(context, redeemState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + + is RedeemPrescriptionsController.State.Ordered -> + withContext(Dispatchers.Main) { + onSuccessfullyOrdered(state.orderOption) + } + } + } finally { + uploadInProgress = false + } + } + } + ) { + if (uploadInProgress) { + val color by ButtonDefaults.buttonColors().contentColor(false) + CircularProgressIndicator(Modifier.size(24.dp), color = color, strokeWidth = 2.dp) + SpacerSmall() + } + Text(buttonText) + } + } + } + }, + topBarTitle = topBarTitle, + listState = listState, + onBack = { navController.popBackStack() } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .testTag(TestTag.PharmacySearch.OrderSummary.Content) + .semantics { + prescriptionIds = state.prescriptions.map { it.first.taskId } + }, + state = listState, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + item { + DescriptionHeader(state.selectedPharmacy.name) + SpacerLarge() + } + item { + Text( + stringResource(R.string.pharmacy_order_contact_and_delivery_address), + style = AppTheme.typography.h6 + ) + } + item { + if (state.prescriptions.isNotEmpty()) { + Contact(state.activeProfile, state.contact, shippingContactCompleted, onClickEdit = { + navController.navigate(PharmacyNavigationScreens.EditShippingContact.path()) + }) + } else { + Box( + Modifier + .fillMaxWidth() + .height(160.dp) + ) { + CircularProgressIndicator( + Modifier + .size(120.dp) + .align(Alignment.Center) + ) + } + } + SpacerLarge() + } + item { + Text(stringResource(R.string.pharmacy_order_title_prescriptions), style = AppTheme.typography.h6) + } + state.prescriptions.forEach { (prescription, selected) -> + item("prescription-${prescription.taskId}") { + Prescription( + modifier = Modifier + .testTag(TestTag.PharmacySearch.OrderSummary.PrescriptionListItem), + prescription = prescription, + selected = selected, + onSelect = { select -> + if (select) { + viewModel.onSelectOrder(prescription) + } else { + viewModel.onDeselectOrder(prescription) + } + } + ) + } + } + } + AnimatedVisibility( + modifier = Modifier.fillMaxSize(), + visible = uploadInProgress, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + Modifier + .fillMaxSize() + .alpha(0.33f) + .background(AppTheme.colors.neutral000) + .semantics(mergeDescendants = false) {} + .pointerInput(Unit) { } + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun Contact( + activeProfile: ProfilesUseCaseData.Profile, + contact: PharmacyUseCaseData.ShippingContact, + shippingContactCompleted: Boolean, + onClickEdit: () -> Unit +) { + Card( + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, AppTheme.colors.neutral300), + elevation = 0.dp, + onClick = onClickEdit + ) { + if (contact.addressIsMissing()) { + Column(Modifier.padding(PaddingDefaults.Medium)) { + Row { + val colors = profileColor(profileColorNames = activeProfile.color) + val ssoStatusColor = ssoStatusColor(activeProfile, activeProfile.ssoTokenScope) + + Avatar( + avatarModifier = Modifier.size(40.dp), + profile = activeProfile, + ssoStatusColor = ssoStatusColor, + emptyIcon = Icons.Rounded.AddAPhoto, + iconModifier = Modifier.size(20.dp) + ) + SpacerMedium() + Text( + stringResource(R.string.pharmacy_order_contact_required), + style = AppTheme.typography.body1 + ) + } + SpacerMedium() + SecondaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + onClick = { onClickEdit() } + ) { + Text(stringResource(R.string.pharmacy_order_edit_contact)) + } + } + } else { + Row(Modifier.padding(PaddingDefaults.Medium)) { + val ssoStatusColor = ssoStatusColor(activeProfile, activeProfile.ssoTokenScope) + + Avatar( + avatarModifier = Modifier.size(40.dp), + profile = activeProfile, + ssoStatusColor = ssoStatusColor, + emptyIcon = Icons.Rounded.PersonOutline, + iconModifier = Modifier.size(20.dp) + ) + SpacerMedium() + Column(Modifier.weight(1f)) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column { + if (contact.name.isNotBlank()) { + Text(contact.name, style = AppTheme.typography.subtitle1) + } + contact.address().forEach { + Text(it, style = AppTheme.typography.body1) + } + } + if (contact.other().isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (contact.telephoneNumber.isNotBlank()) { + SmallChip(Icons.Outlined.Phone, contact.telephoneNumber) + } + if (contact.mail.isNotBlank()) { + SmallChip(Icons.Outlined.Mail, contact.mail) + } + } + } + if (contact.deliveryInformation.isNotBlank()) { + Text(contact.deliveryInformation, style = AppTheme.typography.body1l) + } + } + if (!shippingContactCompleted) { + SpacerSmall() + Surface(shape = RoundedCornerShape(8.dp), color = AppTheme.colors.red100) { + Text( + stringResource(R.string.pharmacy_order_further_contact_information_required), + color = AppTheme.colors.red900, + style = AppTheme.typography.subtitle2, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + SpacerMedium() + Icon(Icons.Rounded.Edit, null, tint = AppTheme.colors.neutral400) + } + } + } +} + +@Composable +private fun SmallChip( + icon: ImageVector, + text: String +) { + Surface(shape = RoundedCornerShape(8.dp), color = AppTheme.colors.neutral100) { + Row( + Modifier.padding(horizontal = PaddingDefaults.Small, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, null, tint = AppTheme.colors.neutral500) + SpacerSmall() + Text( + text, + style = AppTheme.typography.body1 + ) + } + } +} + +@Preview +@Composable +private fun SmallChipPreview() { + AppTheme { + SmallChip(Icons.Outlined.Phone, "0049123456789") + } +} + +@Preview +@Composable +private fun ContactPreview() { + AppTheme { + Contact( + activeProfile = ProfilesUseCaseData.Profile( + id = "0", + name = "Irina Muster", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + lastAuthenticated = null, + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage + ), + contact = PharmacyUseCaseData.ShippingContact( + name = "Beate Muster", + line1 = "Friedrichstraße 123", + line2 = "Test", + postalCodeAndCity = "10998 Berlin", + telephoneNumber = "00123456789", + mail = "mailadresse@provider.de", + deliveryInformation = "Bitte im Vorderhaus bei Familie Schmidt abgeben." + ), + onClickEdit = {}, + shippingContactCompleted = false + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun Prescription( + modifier: Modifier = Modifier, + prescription: PharmacyUseCaseData.PrescriptionOrder, + selected: Boolean, + onSelect: (Boolean) -> Unit +) { + Card( + modifier = modifier + .semantics { + this.selected = selected + this.prescriptionId = prescription.taskId + }, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, AppTheme.colors.neutral300), + elevation = 0.dp, + onClick = { onSelect(!selected) } + ) { + Row(Modifier.padding(PaddingDefaults.Medium)) { + Column( + Modifier + .weight(1f) + .then( + if (prescription.substitutionsAllowed) Modifier + else Modifier.align(Alignment.CenterVertically) + ) + ) { + val prescriptionTitle = if (prescription.scannedOn != null) { + stringResource(R.string.order_scanned_prescription_header) + } else { + prescription.title ?: stringResource(R.string.prescription_medication_default_name) + } + Text( + prescriptionTitle, + style = AppTheme.typography.subtitle1, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + if (prescription.scannedOn != null) { + dateTimeShortText(prescription.scannedOn) + Text( + text = stringResource( + R.string.order_scanned_on_info, + dateTimeShortText(prescription.scannedOn) + ), + style = AppTheme.typography.body2l + ) + } + if (prescription.substitutionsAllowed) { + SpacerSmall() + Text( + text = stringResource(R.string.pres_detail_aut_idem_info), + style = AppTheme.typography.body2l + ) + } + } + SpacerMedium() + if (selected) { + Icon(Icons.Filled.CheckCircle, null, tint = AppTheme.colors.primary600) + } else { + Icon(Icons.Filled.RadioButtonUnchecked, null, tint = AppTheme.colors.neutral400) + } + } + } +} + +@Preview +@Composable +private fun SelectedPrescriptionPreview() { + AppTheme { + Prescription( + prescription = PharmacyUseCaseData.PrescriptionOrder( + taskId = "", + title = "Ivermectin", + substitutionsAllowed = false, + accessCode = "" + ), + selected = true, + onSelect = {} + ) + } +} + +@Preview +@Composable +private fun UnselectedPrescriptionPreview() { + AppTheme { + Prescription( + prescription = PharmacyUseCaseData.PrescriptionOrder( + taskId = "", + title = "Ivermectin", + substitutionsAllowed = true, + accessCode = "" + ), + selected = false, + onSelect = {} + ) + } +} + +@Composable +private fun DescriptionHeader(pharmacyName: String) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = annotatedStringResource( + id = R.string.pharm_reserve_subheader, + buildAnnotatedString { + withStyle(AppTheme.typography.subtitle2.toSpanStyle()) { + append(pharmacyName) + } + } + ), + textAlign = TextAlign.Center, + style = AppTheme.typography.body2l + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt new file mode 100644 index 00000000..8a49fd8a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.kt @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.location.LocationManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.tasks.CancellationTokenSource +import de.gematik.ti.erp.app.fhir.model.DeliveryPharmacyService +import de.gematik.ti.erp.app.fhir.model.Location +import de.gematik.ti.erp.app.fhir.model.isOpenAt +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyMapsUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.prescription.ui.catchAndTransformRemoteExceptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.kodein.di.compose.rememberInstance +import java.time.OffsetDateTime + +private const val WaitForLocationUpdate = 2500L +private const val DefaultRadiusInMeter = 999 * 1000.0 + +private val DefaultSearchData = PharmacyUseCaseData.SearchData( + name = "", + filter = PharmacyUseCaseData.Filter(), + locationMode = PharmacyUseCaseData.LocationMode.Disabled +) + +@Stable +class PharmacySearchController( + private val context: Context, + private val mapsUseCase: PharmacyMapsUseCase, + private val searchUseCase: PharmacySearchUseCase, + coroutineScope: CoroutineScope +) { + @Stable + sealed interface State : PrescriptionServiceState { + @Stable + object Loading : State + + @Stable + data class Pharmacies(val pharmacies: List) : State + } + + private val searchChannelFlow = MutableStateFlow(null) + + var isLoading by mutableStateOf(false) + private set + + var searchState by mutableStateOf(DefaultSearchData) + private set + + @OptIn(ExperimentalCoroutinesApi::class) + val pharmacySearchFlow: Flow> = + searchChannelFlow + .filterNotNull() + .onEach { + // if we receive an empty list as the first page and the last searchPagingItems state was already populated with results, + // the continues loading won't work; this short timeout is an ugly workaround to this issue + delay(100) + searchState = it + } + .flatMapLatest { searchData -> + isLoading = true + + searchUseCase.searchPharmacies(searchData) + .map { pagingData -> + if (searchData.locationMode is PharmacyUseCaseData.LocationMode.Enabled) { + pagingData.map { + it.copy( + distance = it.location?.minus(searchData.locationMode.location) + ) + } + } else { + pagingData + }.filter { pharmacy -> + if (searchData.filter.deliveryService) { + when { + searchData.filter.deliveryService && + pharmacy.provides.any { it is DeliveryPharmacyService } -> true + + else -> false + } + } else { + true + } + }.filter { + if (searchData.filter.openNow) { + when { + it.openingHours == null -> false + it.openingHours.isOpenAt(OffsetDateTime.now()) -> true + else -> false + } + } else { + true + } + }.map { + PharmacySearchUi.Pharmacy(it) + } + }.cachedIn(coroutineScope) + } + .onEach { + isLoading = false + } + .flowOn(Dispatchers.IO) + .shareIn( + coroutineScope, + SharingStarted.WhileSubscribed(), + 1 + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val pharmacyMapsFlow: Flow = + searchChannelFlow + .filterNotNull() + .onEach { + searchState = it + } + .flatMapLatest { searchData -> + flow { + emit(State.Loading) + + val pharmacies = mapsUseCase.searchPharmacies(searchData) + + if (searchData.locationMode is PharmacyUseCaseData.LocationMode.Enabled) { + pharmacies.map { + it.copy( + distance = it.location?.minus(searchData.locationMode.location) + ) + } + } else { + pharmacies + }.filter { pharmacy -> + if (searchData.filter.deliveryService) { + when { + searchData.filter.deliveryService && + pharmacy.provides.any { it is DeliveryPharmacyService } -> true + + else -> false + } + } else { + true + } + }.filter { + if (searchData.filter.openNow) { + when { + it.openingHours == null -> false + it.openingHours.isOpenAt(OffsetDateTime.now()) -> true + else -> false + } + } else { + true + } + }.also { + emit(State.Pharmacies(it)) + } + }.catchAndTransformRemoteExceptions() + } + .onEach { + isLoading = when (it) { + is State.Loading -> + true + + else -> + false + } + } + .flowOn(Dispatchers.IO) + + enum class SearchQueryResult { + Send, NoLocationPermission, NoLocationFound, NoLocationServicesEnabled + } + + private val lock = Mutex() + + suspend fun search( + name: String, + filter: PharmacyUseCaseData.Filter, + location: Location? = null, + radiusInMeter: Double = DefaultRadiusInMeter + ): SearchQueryResult = withContext(Dispatchers.IO) { + lock.withLock { + try { + isLoading = true + + val hasLocationPermission = anyLocationPermissionGranted(context) + val hasLocationServiceEnabled = isLocationServiceEnabled(context) + + val currentLocation = + location ?: if (hasLocationPermission && hasLocationServiceEnabled && filter.nearBy) { + queryLocation(context) + } else { + null + } + + val locationMode = currentLocation?.let { + PharmacyUseCaseData.LocationMode.Enabled(it, radiusInMeter) + } ?: PharmacyUseCaseData.LocationMode.Disabled + + val locationError = if (location == null && filter.nearBy) { + when { + !hasLocationServiceEnabled -> SearchQueryResult.NoLocationServicesEnabled + !hasLocationPermission -> SearchQueryResult.NoLocationPermission + locationMode == PharmacyUseCaseData.LocationMode.Disabled -> SearchQueryResult.NoLocationFound + else -> null + } + } else { + null + } + + when { + locationError != null -> { + locationError + } + + else -> { + searchChannelFlow.emit( + PharmacyUseCaseData.SearchData( + name = name, + filter = filter.copy( + nearBy = locationMode is PharmacyUseCaseData.LocationMode.Enabled && + location == null + ), + locationMode = locationMode + ) + ) + SearchQueryResult.Send + } + } + } finally { + isLoading = false + } + } + } +} + +@RequiresApi(Build.VERSION_CODES.P) +private fun isLocationServiceEnabled(context: Context): Boolean { + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return lm.isLocationEnabled +} + +suspend fun queryLocation(context: Context): Location? = + queryNativeLocation(context)?.let { + Location(longitude = it.longitude, latitude = it.latitude) + } + +@SuppressLint("MissingPermission") +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun queryNativeLocation(context: Context): android.location.Location? = + withTimeoutOrNull(WaitForLocationUpdate) { + if (anyLocationPermissionGranted(context)) { + suspendCancellableCoroutine { continuation -> + val cancelTokenSource = CancellationTokenSource() + + continuation.invokeOnCancellation { cancelTokenSource.cancel() } + + LocationServices + .getFusedLocationProviderClient(context) + .getCurrentLocation(LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY, cancelTokenSource.token) + .addOnFailureListener { + continuation.cancel() + } + .addOnSuccessListener { + continuation.resume(it, null) + } + } + } else { + null + } + } + +@Composable +fun rememberPharmacySearchController(): PharmacySearchController { + val context = LocalContext.current + val pharmacyMapsUseCase by rememberInstance() + val pharmacySearchUseCase by rememberInstance() + val scope = rememberCoroutineScope() + return remember { + PharmacySearchController( + context = context, + mapsUseCase = pharmacyMapsUseCase, + searchUseCase = pharmacySearchUseCase, + coroutineScope = scope + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt new file mode 100644 index 00000000..a10e670e --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchOverview.kt @@ -0,0 +1,609 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Moped +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData +import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyOverviewViewModel +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel + +private const val LastUsedPharmaciesListLength = 5 + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PharmacyOverviewScreen( + onBack: () -> Unit, + onStartSearch: () -> Unit, + onShowMaps: () -> Unit, + filter: PharmacyUseCaseData.Filter, + onFilterChange: (PharmacyUseCaseData.Filter) -> Unit, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit +) { + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val modal = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + + ModalBottomSheetLayout( + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetContent = { + FilterSheetContent( + modifier = Modifier.navigationBarsPadding(), + filter = filter, + onClickChip = onFilterChange, + onClickClose = { scope.launch { modal.hide() } }, + extraContent = { + SpacerLarge() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + PrimaryButtonSmall( + onClick = { + scope.launch { modal.hide() } + onStartSearch() + } + ) { + Text(stringResource(R.string.search_pharmacies_start_search)) + } + } + SpacerLarge() + } + ) + }, + sheetState = modal + ) { + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.PharmacySearch.OverviewScreen), + listState = listState, + topBarTitle = stringResource(R.string.redeem_header), + onBack = onBack + ) { + val pharmacyViewModel by rememberViewModel() + OverviewContent( + onSelectPharmacy = onSelectPharmacy, + listState = listState, + onFilterChange = onFilterChange, + searchFilter = filter, + onStartSearch = onStartSearch, + pharmacyViewModel = pharmacyViewModel, + onShowFilter = { + scope.launch { modal.show() } + }, + onShowMaps = onShowMaps + ) + } + } +} + +@Composable +private fun OverviewContent( + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + listState: LazyListState, + searchFilter: PharmacyUseCaseData.Filter, + onFilterChange: (PharmacyUseCaseData.Filter) -> Unit, + onStartSearch: () -> Unit, + pharmacyViewModel: PharmacyOverviewViewModel, + onShowFilter: () -> Unit, + onShowMaps: () -> Unit +) { + val overviewPharmacyList by produceState(initialValue = listOf()) { + pharmacyViewModel.pharmacyOverviewState().collect { value = it } + } + + val contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom) + .add(WindowInsets(top = PaddingDefaults.Medium, bottom = PaddingDefaults.Medium)).asPaddingValues() + + val context = LocalContext.current + val isGoogleApiAvailable by remember { + mutableStateOf( + GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(TestTag.PharmacySearch.OverviewContent), + state = listState, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = contentPadding + ) { + item { + PharmacySearchButton( + modifier = Modifier + .padding(horizontal = PaddingDefaults.Medium) + .testTag(TestTag.PharmacySearch.TextSearchButton) + ) { + onFilterChange( + searchFilter.copy( + ready = true, + onlineService = false, + deliveryService = false, + openNow = false + ) + ) + onStartSearch() + } + } + if (isGoogleApiAvailable) { + item { + MapsSection(onShowMaps = onShowMaps) + } + } + item { + FilterSection( + filter = searchFilter, + onClick = onFilterChange, + onClickFilter = onShowFilter, + onStartSearch = onStartSearch + ) + } + item { + OverviewPharmacies( + oftenUsedPharmacyList = overviewPharmacyList, + onSelectPharmacy = onSelectPharmacy, + pharmacyViewModel = pharmacyViewModel + ) + } + } +} + +@Composable +private fun MapsSection( + onShowMaps: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.pharmacy_maps_header), + style = AppTheme.typography.subtitle1, + modifier = Modifier + .padding(top = PaddingDefaults.XXLarge, bottom = PaddingDefaults.Medium) + .padding(horizontal = PaddingDefaults.Medium) + ) + } + MapsOverviewSmall( + modifier = Modifier + .fillMaxWidth() + .height(186.dp) + .padding(horizontal = PaddingDefaults.Medium), + onClick = onShowMaps + ) +} + +@Composable +private fun OverviewPharmacies( + oftenUsedPharmacyList: List, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + pharmacyViewModel: PharmacyOverviewViewModel +) { + if (oftenUsedPharmacyList.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) + ) { + Text( + stringResource(R.string.pharmacy_my_pharmacies_header), + style = AppTheme.typography.subtitle1, + modifier = Modifier.padding(top = PaddingDefaults.XXLarge, bottom = PaddingDefaults.Medium), + textAlign = TextAlign.Start + ) + val shortOftenUsedPharmacyList = remember { oftenUsedPharmacyList.take(LastUsedPharmaciesListLength) } + Column { + for (oftenUsedPharmacy in shortOftenUsedPharmacyList) { + OverviewPharmacyCard( + overviewPharmacy = oftenUsedPharmacy, + onSelectPharmacy = onSelectPharmacy, + pharmacyViewModel = pharmacyViewModel + ) + SpacerMedium() + } + } + } + } +} + +@Stable +private sealed interface RefreshState { + @Stable + object Loading : RefreshState + + @Stable + class Success(val pharmacy: List) : RefreshState + + @Stable + object NotFound : RefreshState + + @Stable + object Error : RefreshState +} + +@Composable +private fun OverviewPharmacyCard( + overviewPharmacy: OverviewPharmacyData.OverviewPharmacy, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + pharmacyViewModel: PharmacyOverviewViewModel +) { + var showFailedPharmacyCallDialog by remember { mutableStateOf(false) } + var showNoInternetConnectionDialog by remember { mutableStateOf(false) } + + val refreshFlow = remember { MutableSharedFlow() } + var state by remember { mutableStateOf(RefreshState.Loading) } + + LaunchedEffect(Unit) { + refreshFlow + .onStart { emit(Unit) } // emit once to start the flow directly + .collectLatest { + state = RefreshState.Loading + pharmacyViewModel.findPharmacyByTelematikIdState(overviewPharmacy.telematikId).first().fold( + onFailure = { + Napier.e("Could not find pharmacy by telematikId", it) + state = RefreshState.Error + }, + onSuccess = { + state = if (it.isEmpty()) { + RefreshState.NotFound + } else { + RefreshState.Success(it) + } + showNoInternetConnectionDialog = false + } + ) + } + } + + val scope = rememberCoroutineScope() + + if (showNoInternetConnectionDialog) { + CommonAlertDialog( + header = stringResource(R.string.pharmacy_search_apovz_call_no_internet_header), + info = stringResource(R.string.pharmacy_search_apovz_call_no_internet_info), + cancelText = stringResource(R.string.pharmacy_search_apovz_call_no_internet_cancel), + actionText = stringResource(R.string.pharmacy_search_apovz_call_no_internet_retry), + onCancel = { showNoInternetConnectionDialog = false }, + onClickAction = { + scope.launch { + refreshFlow.onStart { emit(Unit) }.collectLatest { + state = RefreshState.Loading + pharmacyViewModel.findPharmacyByTelematikIdState( + overviewPharmacy.telematikId + ).first().fold( + onFailure = { + Napier.e("Could not find pharmacy by telematikId", it) + state = RefreshState.Error + }, + onSuccess = { + state = if (it.isEmpty()) { + RefreshState.NotFound + } else { + RefreshState.Success(it) + } + showNoInternetConnectionDialog = false + } + ) + } + } + } + ) + } + if (showFailedPharmacyCallDialog) { + AcceptDialog( + header = stringResource(R.string.pharmacy_search_apovz_call_failed_header), + info = stringResource(R.string.pharmacy_search_apovz_call_failed_body), + onClickAccept = { + scope.launch { pharmacyViewModel.deleteOverviewPharmacy(overviewPharmacy) } + showFailedPharmacyCallDialog = false + }, + acceptText = stringResource(R.string.pharmacy_search_apovz_call_failed_accept) + ) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(role = Role.Button) { + when (state) { + is RefreshState.Success -> onSelectPharmacy((state as RefreshState.Success).pharmacy.first()) + is RefreshState.Error -> showNoInternetConnectionDialog = true + is RefreshState.NotFound -> showFailedPharmacyCallDialog = true + else -> {} + } + }, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, AppTheme.colors.neutral300), + elevation = 0.dp + ) { + PharmacyCardContent(overviewPharmacy) + } +} + +@Composable +private fun PharmacyCardContent(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround + ) { + PharmacyImagePlaceholder(Modifier.padding(PaddingDefaults.Medium)) + + Column( + modifier = Modifier + .padding( + end = PaddingDefaults.Medium, + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + ) + .weight(1f) + ) { + Text( + overviewPharmacy.pharmacyName, + style = AppTheme.typography.subtitle1 + ) + Text( + overviewPharmacy.address, + style = AppTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (overviewPharmacy.isFavorite) { + Icon( + Icons.Rounded.Star, + contentDescription = null, + modifier = Modifier + .padding(end = PaddingDefaults.Medium) + .size(24.dp), + tint = AppTheme.colors.yellow500 + ) + } + } +} + +@Composable +private fun FilterSection( + filter: PharmacyUseCaseData.Filter, + onClick: (PharmacyUseCaseData.Filter) -> Unit, + onStartSearch: () -> Unit, + onClickFilter: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center + ) { + Text( + stringResource(R.string.search_pharmacies_filter_header), + style = AppTheme.typography.subtitle1, + modifier = Modifier + .padding(top = PaddingDefaults.XXLarge, bottom = PaddingDefaults.Medium) + .padding(horizontal = PaddingDefaults.Medium), + textAlign = TextAlign.Start + ) + FilterButton( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + text = stringResource(R.string.search_pharmacies_filter_open_now_and_local), + icon = Icons.Outlined.LocationOn, + onClick = { + onClick( + filter.copy( + nearBy = true, + ready = true, + openNow = true, + deliveryService = false, + onlineService = false + ) + ) + onStartSearch() + } + ) + FilterButton( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + text = stringResource(R.string.search_pharmacies_filter_delivery_service), + icon = Icons.Outlined.Moped, + onClick = { + onClick( + filter.copy( + nearBy = true, + ready = true, + deliveryService = true, + onlineService = false, + openNow = false + ) + ) + onStartSearch() + } + ) + FilterButton( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + text = stringResource(R.string.search_pharmacies_filter_online_service), + icon = Icons.Outlined.LocalShipping, + onClick = { + onClick( + filter.copy( + nearBy = false, + ready = true, + onlineService = true, + deliveryService = false, + openNow = false + ) + ) + onStartSearch() + } + ) + FilterButton( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + text = stringResource(R.string.search_pharmacies_filter_by), + icon = Icons.Outlined.Tune, + onClick = onClickFilter + ) + } +} + +@Composable +private fun FilterButton( + modifier: Modifier, + text: String, + icon: ImageVector, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button) { onClick() } + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Icon( + icon, + null, + tint = AppTheme.colors.neutral600 + ) + SpacerMedium() + Text( + text, + modifier = Modifier.padding(vertical = PaddingDefaults.Medium), + color = AppTheme.colors.neutral900, + style = AppTheme.typography.body1, + fontWeight = FontWeight.W400 + ) + } +} + +@Composable +private fun PharmacySearchButton( + modifier: Modifier, + onStartSearch: () -> Unit +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(color = AppTheme.colors.neutral050, shape = RoundedCornerShape(16.dp)) + .clickable(role = Role.Button) { onStartSearch() } + .padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.ShortMedium) + ) { + Icon( + Icons.Rounded.Search, + tint = AppTheme.colors.neutral600, + contentDescription = null + ) + SpacerSmall() + Text( + text = stringResource(R.string.pharmacy_start_search_text), + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f), + style = AppTheme.typography.body1, + color = AppTheme.colors.neutral600 + ) + } +} + +@Composable +fun PharmacyImagePlaceholder(modifier: Modifier) { + Image( + painterResource(R.drawable.ic_green_cross), + null, + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .size(64.dp) + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreen.kt new file mode 100644 index 00000000..2d95a9da --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreen.kt @@ -0,0 +1,910 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarResult +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.Map +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemsIndexed +import com.google.accompanist.flowlayout.FlowRow +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.fhir.model.LocalPharmacyService +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.pharmacyId +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import de.gematik.ti.erp.app.utils.compose.AlertDialog +import de.gematik.ti.erp.app.utils.compose.Chip +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.text.DecimalFormat +import java.time.OffsetDateTime + +private const val OneKilometerInMeter = 1000 + +@Composable +private fun PharmacySearchErrorHint( + title: String, + subtitle: String, + action: String? = null, + onClickAction: (() -> Unit)? = null, + modifier: Modifier +) { + Box( + modifier = modifier + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(PaddingDefaults.Medium), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + title, + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + Text( + subtitle, + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + if (action != null && onClickAction != null) { + TextButton(onClick = onClickAction) { + Text(action) + } + } + } + } +} + +@Composable +fun NoLocationDialog( + onAccept: () -> Unit +) { + AcceptDialog( + header = stringResource(R.string.search_pharmacies_location_na_header), + info = stringResource(R.string.search_pharmacies_location_na_header_info), + acceptText = stringResource(R.string.search_pharmacies_location_na_header_okay), + onClickAccept = onAccept + ) +} + +@Composable +fun NoLocationServicesDialog( + onClose: () -> Unit +) { + val context = LocalContext.current + AlertDialog( + title = { Text(stringResource(R.string.search_pharmacies_location_na_header)) }, + onDismissRequest = {}, + text = { Text(stringResource(R.string.search_pharmacies_location_na_services)) }, + buttons = { + TextButton(onClick = onClose) { + Text(stringResource(R.string.cancel)) + } + TextButton(onClick = { + context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + onClose() + }) { + Text(stringResource(R.string.search_pharmacies_location_na_settings)) + } + }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) +} + +@Composable +private fun PharmacySearchInputfield( + modifier: Modifier, + onBack: () -> Unit, + isLoading: Boolean, + searchValue: String, + onSearchChange: (String) -> Unit, + onSearch: (String) -> Unit +) { + var isLoadingStable by remember { mutableStateOf(isLoading) } + + LaunchedEffect(isLoading) { + delay(timeMillis = 330) + isLoadingStable = isLoading + } + + TextField( + value = searchValue, + onValueChange = onSearchChange, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium), + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrect = true, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions { + onSearch(searchValue) + }, + visualTransformation = VisualTransformation.None, + trailingIcon = { + Crossfade(isLoadingStable, animationSpec = tween(durationMillis = 550)) { + if (it) { + Box(Modifier.size(48.dp)) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + strokeWidth = 2.dp + ) + } + } else { + IconButton( + onClick = { onSearchChange("") } + ) { + Icon( + Icons.Rounded.Close, + contentDescription = null + ) + } + } + } + }, + leadingIcon = { + IconButton( + onClick = { onBack() } + ) { + Icon( + Icons.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + shape = RoundedCornerShape(16.dp), + textStyle = AppTheme.typography.body1, + colors = TextFieldDefaults.textFieldColors( + textColor = AppTheme.colors.neutral900, + leadingIconColor = AppTheme.colors.neutral600, + trailingIconColor = AppTheme.colors.neutral600, + backgroundColor = AppTheme.colors.neutral050, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) +} + +@Composable +private fun FilterSection( + filter: PharmacyUseCaseData.Filter, + onClickChip: (PharmacyUseCaseData.Filter) -> Unit, + onClickFilter: () -> Unit +) { + val rowState = rememberLazyListState() + Row(modifier = Modifier.fillMaxWidth()) { + SpacerMedium() + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { + onClickFilter() + } + .background(color = AppTheme.colors.neutral100, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = PaddingDefaults.Small, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Rounded.Tune, null, Modifier.size(16.dp), tint = AppTheme.colors.primary600) + SpacerSmall() + Text( + stringResource(R.string.search_pharmacies_filter), + style = AppTheme.typography.subtitle2, + color = AppTheme.colors.primary600 + ) + } + if (filter.isAnySet()) { + SpacerSmall() + LazyRow( + state = rowState, + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), + modifier = Modifier.fillMaxWidth() + ) { + if (filter.nearBy) { + item { + Chip( + stringResource(R.string.search_pharmacies_filter_nearby), + closable = true, + checked = false + ) { + onClickChip(filter.copy(nearBy = false)) + } + } + } + if (filter.openNow) { + item { + Chip( + stringResource(R.string.search_pharmacies_filter_open_now), + closable = true, + checked = false + ) { + onClickChip(filter.copy(openNow = false)) + } + } + } + if (filter.ready) { + item { + Chip( + stringResource(R.string.search_pharmacies_filter_e_prescription_ready), + closable = true, + checked = false + ) { + onClickChip(filter.copy(ready = false)) + } + } + } + if (filter.deliveryService) { + item { + Chip( + stringResource(R.string.search_pharmacies_filter_delivery_service), + closable = true, + checked = false + ) { + onClickChip(filter.copy(deliveryService = false)) + } + } + } + if (filter.onlineService) { + item { + Chip( + stringResource(R.string.search_pharmacies_filter_online_service), + closable = true, + checked = false + ) { + onClickChip(filter.copy(onlineService = false)) + } + } + } + item { + SpacerSmall() + } + } + } + } +} + +@Composable +fun FilterSheetContent( + modifier: Modifier, + extraContent: @Composable () -> Unit = {}, + filter: PharmacyUseCaseData.Filter, + onClickChip: (PharmacyUseCaseData.Filter) -> Unit, + onClickClose: () -> Unit, + showNearByFilter: Boolean = true +) { + var filterValue by remember(filter) { mutableStateOf(filter) } + + val onClickChipFn = { f: PharmacyUseCaseData.Filter -> + filterValue = f + onClickChip(f) + } + + Column( + modifier.padding(PaddingDefaults.Medium) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.search_pharmacies_filter_header), + style = AppTheme.typography.h6 + ) + IconButton( + modifier = Modifier + .background(AppTheme.colors.neutral100, CircleShape), + onClick = onClickClose + ) { + Icon( + Icons.Rounded.Close, + null + ) + } + } + SpacerMedium() + Column(modifier = Modifier.verticalScroll(rememberScrollState(), true)) { + FlowRow( + mainAxisSpacing = PaddingDefaults.Small, + crossAxisSpacing = PaddingDefaults.Small + ) { + if (showNearByFilter) { + Chip( + stringResource(R.string.search_pharmacies_filter_nearby), + closable = false, + checked = filterValue.nearBy + ) { + onClickChipFn( + filterValue.copy( + nearBy = it + ) + ) + } + } + Chip( + stringResource(R.string.search_pharmacies_filter_open_now), + closable = false, + checked = filterValue.openNow + ) { + onClickChipFn( + filterValue.copy( + openNow = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_e_prescription_ready), + closable = false, + checked = filterValue.ready + ) { + onClickChipFn( + filterValue.copy( + ready = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_delivery_service), + closable = false, + checked = filterValue.deliveryService + ) { + onClickChipFn( + filterValue.copy( + nearBy = if (it) true else filterValue.nearBy, + deliveryService = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_online_service), + closable = false, + checked = filterValue.onlineService + ) { + onClickChipFn( + filterValue.copy( + onlineService = it + ) + ) + } + } + + extraContent() + } + } +} + +@Composable +private fun PharmacyResultCard( + modifier: Modifier, + pharmacy: PharmacyUseCaseData.Pharmacy, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .then(modifier), + verticalAlignment = Alignment.CenterVertically + ) { + val distanceTxt = pharmacy.distance?.let { distance -> + formattedDistance(distance) + } + + PharmacyImagePlaceholder(Modifier) + SpacerMedium() + Column(modifier = Modifier.weight(1f)) { + Text( + pharmacy.name, + style = AppTheme.typography.subtitle1 + ) + + Text( + pharmacy.removeLineBreaksFromAddress(), + style = AppTheme.typography.body2l, + modifier = Modifier, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + + val pharmacyLocalServices = pharmacy.provides.find { it is LocalPharmacyService } as LocalPharmacyService + val now = OffsetDateTime.now() + + if (pharmacyLocalServices.isOpenAt(now)) { + val text = if (pharmacyLocalServices.isAllDayOpen(now.dayOfWeek)) { + stringResource(R.string.search_pharmacy_continuous_open) + } else { + stringResource( + R.string.search_pharmacy_open_until, + requireNotNull(pharmacyLocalServices.openUntil(now)).toString() + ) + } + Text( + text, + style = AppTheme.typography.subtitle2l, + color = AppTheme.colors.green600 + ) + } else { + val text = + pharmacyLocalServices.opensAt(now)?.let { + stringResource( + R.string.search_pharmacy_opens_at, + it.toString() + ) + } + if (text != null) { + Text( + text, + style = AppTheme.typography.subtitle2l, + color = AppTheme.colors.yellow600 + ) + } + } + } + + SpacerMedium() + + if (distanceTxt != null) { + Text( + distanceTxt, + style = AppTheme.typography.body2l, + modifier = Modifier + .align(Alignment.CenterVertically), + textAlign = TextAlign.End + ) + } + Icon( + Icons.Rounded.KeyboardArrowRight, + null, + tint = AppTheme.colors.neutral400, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } +} + +private fun formattedDistance(distanceInMeters: Double): String { + val f = DecimalFormat() + return if (distanceInMeters < OneKilometerInMeter) { + f.maximumFractionDigits = 0 + f.format(distanceInMeters).toString() + " m" + } else { + f.maximumFractionDigits = 1 + f.format(distanceInMeters / OneKilometerInMeter).toString() + " km" + } +} + +@Composable +private fun ErrorRetryHandler( + searchPagingItems: LazyPagingItems, + scaffoldState: ScaffoldState +) { + val errorTitle = stringResource(R.string.search_pharmacy_error_title) + val errorAction = stringResource(R.string.search_pharmacy_error_action) + + LaunchedEffect(searchPagingItems.loadState) { + searchPagingItems.loadState.let { + val anyErr = it.append is LoadState.Error || + it.prepend is LoadState.Error || + it.refresh is LoadState.Error + if (anyErr && searchPagingItems.itemCount > 1) { + val result = + scaffoldState.snackbarHostState.showSnackbar( + errorTitle, + errorAction, + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + searchPagingItems.retry() + } + } + } + } +} + +@Suppress("LongMethod") +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PharmacySearchResultScreen( + pharmacySearchController: PharmacySearchController, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + onClickMaps: () -> Unit, + onBack: () -> Unit +) { + val searchPagingItems = pharmacySearchController.pharmacySearchFlow.collectAsLazyPagingItems() + + val scaffoldState = rememberScaffoldState() + + var searchName by remember(pharmacySearchController.searchState.name) { + mutableStateOf(pharmacySearchController.searchState.name) + } + var searchFilter by remember(pharmacySearchController.searchState.filter) { + mutableStateOf(pharmacySearchController.searchState.filter) + } + + ErrorRetryHandler( + searchPagingItems, + scaffoldState + ) + + val scope = rememberCoroutineScope() + + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + scope.launch { + pharmacySearchController.search( + name = searchName, + filter = searchFilter.copy(nearBy = permissions.values.any { it }) + ) + } + } + + var showNoLocationDialog by remember { mutableStateOf(false) } + if (showNoLocationDialog) { + NoLocationDialog( + onAccept = { + scope.launch { + pharmacySearchController.search( + name = searchName, + filter = searchFilter.copy(nearBy = false) + ) + } + showNoLocationDialog = false + } + ) + } + + var showNoLocationServicesDialog by remember { mutableStateOf(false) } + if (showNoLocationServicesDialog) { + NoLocationServicesDialog( + onClose = { + scope.launch { + pharmacySearchController.search( + name = searchName, + filter = searchFilter.copy(nearBy = false) + ) + } + showNoLocationServicesDialog = false + } + ) + } + + val loadState = searchPagingItems.loadState + val isLoading by derivedStateOf { + pharmacySearchController.isLoading || listOf(loadState.prepend, loadState.append, loadState.refresh) + .any { + when (it) { + is LoadState.NotLoading -> false // initial ui only loading indicator + is LoadState.Loading -> true + else -> false + } + } + } + + val modal = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + + val focusManager = LocalFocusManager.current + + ModalBottomSheetLayout( + modifier = Modifier.fillMaxSize(), + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetContent = { + FilterSheetContent( + modifier = Modifier.navigationBarsPadding(), + filter = searchFilter, + onClickChip = { + focusManager.clearFocus() + scope.launch { + when (pharmacySearchController.search(name = searchName, filter = it)) { + PharmacySearchController.SearchQueryResult.Send -> {} + PharmacySearchController.SearchQueryResult.NoLocationPermission -> { + locationPermissionLauncher.launch(locationPermissions) + } + PharmacySearchController.SearchQueryResult.NoLocationServicesEnabled -> { + showNoLocationServicesDialog = true + } + + PharmacySearchController.SearchQueryResult.NoLocationFound -> { + searchFilter = searchFilter.copy(nearBy = false) + } + } + } + }, + onClickClose = { scope.launch { modal.hide() } } + ) + }, + sheetState = modal + ) { + Scaffold( + modifier = Modifier + .systemBarsPadding() + .testTag(TestTag.PharmacySearch.ResultScreen), + floatingActionButton = { + Button( + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = AppTheme.colors.neutral050, + contentColor = AppTheme.colors.primary600 + ), + modifier = Modifier.size(56.dp), + onClick = onClickMaps + ) { + Icon( + Icons.Rounded.Map, + contentDescription = null + ) + } + } + ) { innerPadding -> + Column(Modifier.padding(innerPadding)) { + SpacerMedium() + PharmacySearchInputfield( + modifier = Modifier.testTag(TestTag.PharmacySearch.TextSearchField), + onBack = onBack, + isLoading = isLoading, + searchValue = searchName, + onSearchChange = { searchName = it }, + onSearch = { + focusManager.clearFocus() + scope.launch { + when (pharmacySearchController.search(name = it, filter = searchFilter)) { + PharmacySearchController.SearchQueryResult.Send -> {} + PharmacySearchController.SearchQueryResult.NoLocationPermission -> { + showNoLocationDialog = true + } + PharmacySearchController.SearchQueryResult.NoLocationServicesEnabled -> { + showNoLocationServicesDialog = true + } + + PharmacySearchController.SearchQueryResult.NoLocationFound -> { + searchFilter = searchFilter.copy(nearBy = false) + } + } + } + } + ) + SpacerSmall() + + FilterSection( + filter = searchFilter, + onClickChip = { + focusManager.clearFocus() + scope.launch { + pharmacySearchController.search(name = searchName, filter = it) + } + }, + onClickFilter = { + focusManager.clearFocus() + scope.launch { modal.show() } + } + ) + + SpacerSmall() + + SearchResultContent( + searchPagingItems = searchPagingItems, + onSelectPharmacy = onSelectPharmacy + ) + } + } + } +} + +@Composable +private fun SearchResultContent( + searchPagingItems: LazyPagingItems, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit +) { + val errorTitle = stringResource(R.string.search_pharmacy_error_title) + val errorSubtitle = stringResource(R.string.search_pharmacy_error_subtitle) + val errorAction = stringResource(R.string.search_pharmacy_error_action) + + val itemPaddingModifier = Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium) + val loadState = searchPagingItems.loadState + + val showNothingFound by derivedStateOf { + listOf(loadState.prepend, loadState.append) + .all { + when (it) { + is LoadState.NotLoading -> + it.endOfPaginationReached && searchPagingItems.itemCount == 0 + + else -> false + } + } && loadState.refresh is LoadState.NotLoading + } + + val showError by derivedStateOf { searchPagingItems.itemCount <= 1 && loadState.refresh is LoadState.Error } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(TestTag.PharmacySearch.ResultContent), + state = rememberLazyListState(), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom) + .asPaddingValues() + ) { + if (showNothingFound) { + item { + PharmacySearchErrorHint( + title = stringResource(R.string.search_pharmacy_nothing_found_header), + subtitle = stringResource(R.string.search_pharmacy_nothing_found_info), + modifier = Modifier + .fillMaxWidth() + .fillParentMaxHeight() + ) + } + } + if (showError) { + item { + PharmacySearchErrorHint( + title = errorTitle, + subtitle = errorSubtitle, + action = errorAction, + onClickAction = { searchPagingItems.retry() }, + modifier = Modifier + .fillMaxWidth() + .fillParentMaxHeight() + ) + } + } + if (loadState.prepend is LoadState.Error) { + item { + PharmacySearchErrorHint( + title = errorTitle, + subtitle = errorSubtitle, + action = errorAction, + onClickAction = { searchPagingItems.retry() }, + modifier = Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium) + ) + } + } + itemsIndexed(searchPagingItems) { index, item -> + when (item) { + is PharmacySearchUi.Pharmacy -> { + Column { + PharmacyResultCard( + modifier = itemPaddingModifier + .semantics { + pharmacyId = item.pharmacy.telematikId + } + .testTag(TestTag.PharmacySearch.PharmacyListEntry), + pharmacy = item.pharmacy + ) { + onSelectPharmacy(item.pharmacy) + } + if (index < searchPagingItems.itemCount - 1) { + Divider(startIndent = PaddingDefaults.Medium) + } + } + } + + null -> {} + } + } + if (loadState.append is LoadState.Error) { + item { + PharmacySearchErrorHint( + title = errorTitle, + subtitle = errorSubtitle, + action = errorAction, + onClickAction = { searchPagingItems.retry() }, + modifier = Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium) + ) + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenComponents.kt deleted file mode 100644 index af99446a..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenComponents.kt +++ /dev/null @@ -1,1079 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.ui - -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.IconToggleButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarResult -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.contentColorFor -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.FilterList -import androidx.compose.material.icons.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.LocationDisabled -import androidx.compose.material.icons.rounded.LocationSearching -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.paging.LoadState -import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.itemsIndexed -import com.google.accompanist.flowlayout.FlowRow -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.rememberInsetsPaddingValues -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyNavigationScreens -import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.AcceptDialog -import de.gematik.ti.erp.app.utils.compose.AnimatedHintCard -import de.gematik.ti.erp.app.utils.compose.Chip -import de.gematik.ti.erp.app.utils.compose.DynamicText -import de.gematik.ti.erp.app.utils.compose.HintSmallImage -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton -import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.SpacerLarge -import de.gematik.ti.erp.app.utils.compose.SpacerMedium -import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.SpacerXLarge -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import java.text.DecimalFormat -import java.time.OffsetDateTime - -@OptIn( - ExperimentalComposeUiApi::class, - ExperimentalMaterialApi::class, - FlowPreview::class -) -@Composable -fun PharmacySearchScreen( - mainNavController: NavController, - navController: NavController, - selectedPharmacy: MutableState, - viewModel: PharmacySearchViewModel = hiltViewModel() -) { - val context = LocalContext.current - - var showLocationHint by remember { mutableStateOf(false) } - val state by produceState(null) { - viewModel.screenState().collect { - value = it - - showLocationHint = it.showLocationHint - } - } - - val locationEnabled by rememberSaveable(state?.search) { - mutableStateOf( - anyLocationPermissionGranted(context) && - state?.search?.let { it.locationMode is PharmacyUseCaseData.LocationMode.Enabled } ?: false - ) - } - var searchText by rememberSaveable(state?.search) { - mutableStateOf(state?.search?.name ?: "") - } - val searchFilter by rememberSaveable(state?.search) { - mutableStateOf(state?.search?.filter ?: PharmacyUseCaseData.Filter()) - } - - val searchListState = rememberLazyListState() - - val searchPagingItems = viewModel.pharmacySearchFlow.collectAsLazyPagingItems() - - val scaffoldState = rememberScaffoldState() - - val errorTitle = stringResource(R.string.search_pharmacy_error_title) - val errorSubtitle = stringResource(R.string.search_pharmacy_error_subtitle) - val errorAction = stringResource(R.string.search_pharmacy_error_action) - LaunchedEffect(searchPagingItems.loadState) { - searchPagingItems.loadState.let { - val anyErr = it.append is LoadState.Error || it.prepend is LoadState.Error || it.refresh is LoadState.Error - if (anyErr && searchPagingItems.itemCount > 1) { - val result = - scaffoldState.snackbarHostState.showSnackbar( - errorTitle, - errorAction, - duration = SnackbarDuration.Short - ) - if (result == SnackbarResult.ActionPerformed) { - searchPagingItems.retry() - } - } - } - } - - lateinit var _search: (searchTxt: String, locEnabled: Boolean, filter: PharmacyUseCaseData.Filter) -> Unit - - fun search( - searchTxt: String = searchText, - locEnabled: Boolean = locationEnabled, - filter: PharmacyUseCaseData.Filter = searchFilter - ) = _search(searchTxt, locEnabled, filter) - - val keyboardController = LocalSoftwareKeyboardController.current - var keyboardHideToggle by remember { mutableStateOf(false) } - DisposableEffect(keyboardHideToggle) { - keyboardController?.hide() - onDispose {} - } - - var showEnableLocationDialog by remember { mutableStateOf(false) } - - if (showEnableLocationDialog) { - EnableLocationDialog { - showEnableLocationDialog = false - } - } - - val scope = rememberCoroutineScope() - - val locationPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> - search(locEnabled = permissions.values.any { it }) - } - - var isPreLoading by remember { mutableStateOf(false) } - _search = { searchTxt: String, - locEnabled: Boolean, - filter: PharmacyUseCaseData.Filter -> - if (locEnabled && !anyLocationPermissionGranted(context)) { - locationPermissionLauncher.launch(locationPermissions) - } else { - scope.launch { - try { - isPreLoading = true - - // workaround for certain huawei devices - keyboardHideToggle = !keyboardHideToggle - - showEnableLocationDialog = viewModel.searchPharmacies( - searchTxt, filter, - locEnabled - ) - } finally { - isPreLoading = false - } - } - } - } - - val loadState = searchPagingItems.loadState - - val modal = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - ModalBottomSheetLayout( - sheetContent = { - FilterBottomSheet( - modifier = Modifier.navigationBarsPadding(), - filter = state?.search?.filter ?: PharmacyUseCaseData.Filter(), - onClickChip = { search(filter = it) }, - onClickClose = { scope.launch { modal.hide() } } - ) - }, - sheetState = modal - ) { - Scaffold( - scaffoldState = scaffoldState, - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = stringResource(R.string.search_pharmacy_title), - onBack = { mainNavController.popBackStack() } - ) - } - ) { - Box { - Column(modifier = Modifier.fillMaxSize()) { - - val itemPaddingModifier = Modifier - .fillMaxWidth() - .padding(PaddingDefaults.Medium) - - val showNothingFound = - listOf(loadState.prepend, loadState.append) - .all { - when (it) { - is LoadState.NotLoading -> - it.endOfPaginationReached && searchPagingItems.itemCount == 1 - else -> false - } - } && loadState.refresh is LoadState.NotLoading - - val showError = searchPagingItems.itemCount <= 1 && loadState.refresh is LoadState.Error - - Box { - var heightLazyColumn by remember { mutableStateOf(1) } - var heightHeader by remember { mutableStateOf(1) } - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .onSizeChanged { heightLazyColumn = it.height }, - state = searchListState, - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) - ) { - item { - Column(modifier = Modifier.onSizeChanged { heightHeader = it.height }) { - SearchField( - searchValue = searchText, - onSearchChange = { searchText = it }, - locationEnabled = locationEnabled, - onSearch = { searchTxt, locEnabled -> - search(searchTxt, locEnabled) - } - ) - - SpacerMedium() - - FilterSection( - filter = searchFilter, - onClickChip = { search(filter = it) }, - onClickFilter = { scope.launch { modal.show() } } - ) - - SpacerMedium() - } - } - if (showNothingFound) { - item { - PharmacySearchErrorHint( - title = stringResource(R.string.search_pharmacy_nothing_found_header), - subtitle = stringResource(R.string.search_pharmacy_nothing_found_info), - modifier = Modifier - .fillMaxWidth() - .fillParentMaxHeight( - 1f - heightHeader / heightLazyColumn.toFloat() - ) - ) - } - } - if (showError) { - item { - PharmacySearchErrorHint( - title = errorTitle, - subtitle = errorSubtitle, - action = errorAction, - onClickAction = { searchPagingItems.retry() }, - modifier = Modifier - .fillMaxWidth() - .fillParentMaxHeight( - 1f - heightHeader / heightLazyColumn.toFloat() - ) - ) - } - } - if (loadState.prepend is LoadState.Error) { - item { - PharmacySearchErrorHint( - title = errorTitle, - subtitle = errorSubtitle, - action = errorAction, - onClickAction = { searchPagingItems.retry() }, - modifier = Modifier - .fillMaxWidth() - .padding(PaddingDefaults.Medium) - ) - } - } - itemsIndexed(searchPagingItems) { index, item -> - when (item) { - PharmacySearchUi.LocationHint -> { - // enable location information - if (showLocationHint) { - AnimatedHintCard( - modifier = Modifier - .padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium - ), - image = { - HintSmallImage( - painterResource(R.drawable.pharmacist_hint), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.search_enable_location_hint_header)) }, - body = { Text(stringResource(R.string.search_enable_location_hint_info)) }, - action = { - HintTextActionButton(stringResource(R.string.search_enable_location_hint_enable)) { - search(searchText, true) - } - }, - onTransitionEnd = { - if (!it) { - viewModel.cancelLocationHint() - } - } - ) - } - } - is PharmacySearchUi.Pharmacy -> { - Column { - PharmacyResultCard( - modifier = itemPaddingModifier, - pharmacy = item.pharmacy - ) { - selectedPharmacy.value = item.pharmacy - navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) - } - if (index < searchPagingItems.itemCount - 1) { - Divider(startIndent = PaddingDefaults.Medium) - } - } - } - null -> { - if (loadState.prepend !is LoadState.Error && loadState.append !is LoadState.Error) { - Column { - PharmacyResultPlaceholder(itemPaddingModifier) - if (index < searchPagingItems.itemCount - 1) { - Divider(startIndent = PaddingDefaults.Medium) - } - } - } - } - } - } - if (loadState.append is LoadState.Error) { - item { - PharmacySearchErrorHint( - title = errorTitle, - subtitle = errorSubtitle, - action = errorAction, - onClickAction = { searchPagingItems.retry() }, - modifier = Modifier - .fillMaxWidth() - .padding(PaddingDefaults.Medium) - ) - } - } - } - - // TODO needs to be fixed -// val showInitialLoadingAnimation = -// state == null && listOf( -// loadState.prepend, -// loadState.append, -// loadState.refresh -// ) -// .any { -// when (it) { -// is LoadState.NotLoading -> state?.search == null -// is LoadState.Loading -> true -// else -> false -// } -// } -// -// val alpha by animateFloatAsState(if (showInitialLoadingAnimation) 1f else 0f) -// -// // initial loading animation -// if (alpha > 0f) { -// RepeatingColumn( -// modifier = Modifier -// .alpha(alpha), -// stepSize = 5 -// ) { -// PharmacyResultPlaceholder(itemPaddingModifier) -// Divider(startIndent = PaddingDefaults.Medium) -// } -// } - } - } - - val isLoading = isPreLoading || listOf(loadState.prepend, loadState.append, loadState.refresh) - .any { - when (it) { - is LoadState.NotLoading -> state?.search == null // initial ui only loading indicator - is LoadState.Loading -> true - else -> false - } - } - - val loadingAlpha by animateFloatAsState( - if (isLoading) 1f else 0f, - animationSpec = tween() - ) - - LinearProgressIndicator( - modifier = Modifier - .alpha(loadingAlpha) - .fillMaxWidth() - ) - } - } - } -} - -@Composable -private fun PharmacySearchErrorHint( - title: String, - subtitle: String, - action: String? = null, - onClickAction: (() -> Unit)? = null, - modifier: Modifier -) { - Box( - modifier = modifier - ) { - Column( - modifier = Modifier - .align(Alignment.Center) - .padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - title, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center - ) - Text( - subtitle, - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center - ) - if (action != null && onClickAction != null) { - TextButton(onClick = onClickAction) { - Text(action) - } - } - } - } -} - -@Composable -private fun EnableLocationDialog( - onClose: () -> Unit -) { - AcceptDialog( - header = stringResource(R.string.search_pharmacies_location_na_header), - info = stringResource(R.string.search_pharmacies_location_na_info), - acceptText = stringResource(R.string.ok), - onClickAccept = onClose, - ) -} - -@Composable -fun RepeatingColumn( - modifier: Modifier = Modifier, - stepSize: Int = 1, - content: @Composable ColumnScope.(Int) -> Unit -) { - require(stepSize >= 1) - var count by remember { mutableStateOf(stepSize) } - - Column( - modifier = modifier, - content = { - repeat(count) { - content(it) - } - Box( - modifier = Modifier - .weight(1f) - .onSizeChanged { if (it.height > 0) count += stepSize } - ) {} - } - ) -} - -@Composable -private fun PharmacyResultPlaceholder( - modifier: Modifier = Modifier -) { - val bgModifier = Modifier - .background(AppTheme.colors.neutral200) - .testTag("pharmacy_search_screen") - - val alphaTransition = rememberInfiniteTransition() - val alpha by alphaTransition.animateFloat( - initialValue = 1f, - targetValue = 0.5f, - animationSpec = infiniteRepeatable( - animation = tween(330, easing = LinearEasing, delayMillis = (0..100).random()), - repeatMode = RepeatMode.Reverse - ) - ) - - Row( - modifier = modifier.alpha(alpha) - ) { - val fontSize = with(LocalDensity.current) { - MaterialTheme.typography.subtitle1.fontSize.toDp() - } - val heightModifier = bgModifier.height(fontSize) - Column(modifier = Modifier.weight(1f)) { - Box( - modifier = heightModifier - .fillMaxWidth(0.8f) - ) - SpacerSmall() - Box( - modifier = heightModifier - .fillMaxWidth(0.4f) - ) - Box( - modifier = heightModifier - .fillMaxWidth(0.25f) - ) - SpacerSmall() - Box( - modifier = heightModifier - .fillMaxWidth(0.6f) - ) - } - SpacerMedium() - Box( - modifier = heightModifier - .width(fontSize * 2) - .align(Alignment.CenterVertically) - ) - } -} - -@Preview -@Composable -fun PharmacyResultPlaceholderPreview() { - AppTheme { - PharmacyResultPlaceholder() - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun SearchField( - searchValue: String, - onSearchChange: (String) -> Unit, - locationEnabled: Boolean, - onSearch: (String, Boolean) -> Unit, -) { - val searchTextInputColor = if (searchValue.isEmpty()) { - AppTheme.colors.neutral400 - } else { - AppTheme.colors.neutral900 - } - - val textStyle = - LocalTextStyle.current.copy(color = contentColorFor(MaterialTheme.colors.surface)) - - Card( - shape = RoundedCornerShape(8.dp), - elevation = 2.dp, - modifier = Modifier - .padding(top = 16.dp, start = 16.dp, end = 16.dp) - .height(48.dp) - ) { - BasicTextField( - value = searchValue, - onValueChange = onSearchChange, - textStyle = textStyle, - cursorBrush = SolidColor(textStyle.color), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Search - ), - keyboardActions = KeyboardActions( - onSearch = { - onSearch(searchValue, locationEnabled) - } - ), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .semantics(true) {} - ) { textField -> - Row( - modifier = Modifier - .fillMaxSize() - ) { - - Icon( - Icons.Rounded.Search, - null, - tint = AppTheme.colors.neutral600, - modifier = Modifier - .align( - Alignment.CenterVertically - ) - .padding(start = 16.dp) - ) - - Box( - modifier = Modifier - .weight(1f) - .align( - Alignment.CenterVertically - ) - .padding(start = 16.dp, end = 16.dp) - ) { - if (searchValue.isEmpty()) { - Text( - text = stringResource(R.string.search_example_input), - color = searchTextInputColor, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - } - textField() - } - - IconToggleButton( - checked = locationEnabled, - onCheckedChange = { - onSearch(searchValue, it) - }, - ) { - when (locationEnabled) { - true -> Icon( - Icons.Rounded.LocationSearching, - null, - tint = AppTheme.colors.primary600 - ) - - false -> Icon( - Icons.Rounded.LocationDisabled, - null, - tint = AppTheme.colors.neutral600 - ) - } - } - } - } - } -} - -@Preview -@Composable -fun SearchFieldPreview() { - AppTheme { - SearchField( - "", - {}, - false, - { _, _ -> } - ) - } -} - -@Composable -fun FilterSection( - filter: PharmacyUseCaseData.Filter, - onClickChip: (PharmacyUseCaseData.Filter) -> Unit, - onClickFilter: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = PaddingDefaults.Medium), - ) { - TextButton(onClickFilter, modifier = Modifier.align(Alignment.End)) { - Icon(Icons.Rounded.FilterList, null) - Text(stringResource(R.string.search_pharmacies_filter)) - } - if (filter.isAnySet()) { - SpacerSmall() - FlowRow( - modifier = Modifier - .padding(top = ButtonDefaults.TextButtonContentPadding.calculateTopPadding()), - mainAxisSpacing = PaddingDefaults.Small, - crossAxisSpacing = PaddingDefaults.Small - ) { - if (filter.openNow) { - Chip( - stringResource(R.string.search_pharmacies_filter_open_now), - closable = true, - checked = false - ) { - onClickChip(filter.copy(openNow = false)) - } - } - if (filter.ready) { - Chip( - stringResource(R.string.search_pharmacies_filter_ready), - closable = true, - checked = false - ) { - onClickChip(filter.copy(ready = false)) - } - } - if (filter.deliveryService) { - Chip( - stringResource(R.string.search_pharmacies_filter_delivery_service), - closable = true, - checked = false - ) { - onClickChip(filter.copy(deliveryService = false)) - } - } - if (filter.onlineService) { - Chip( - stringResource(R.string.search_pharmacies_filter_online_service), - closable = true, - checked = false - ) { - onClickChip(filter.copy(onlineService = false)) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) -@Composable -private fun FilterBottomSheet( - modifier: Modifier, - filter: PharmacyUseCaseData.Filter, - onClickChip: (PharmacyUseCaseData.Filter) -> Unit, - onClickClose: () -> Unit -) { - Column(modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 4.dp, top = 4.dp) - ) { - IconButton(onClick = onClickClose) { - Icon(Icons.Rounded.Close, null, tint = AppTheme.colors.primary700) - } - Spacer(modifier = Modifier.size(20.dp)) - Text( - stringResource(R.string.search_pharmacies_filter_header), - style = MaterialTheme.typography.h6 - ) - } - SpacerLarge() - Column(modifier = Modifier.padding(horizontal = PaddingDefaults.Medium)) { - Text( - stringResource(R.string.search_pharmacies_filter_section_favorites), - style = MaterialTheme.typography.h6 - ) - SpacerMedium() - Column(modifier = Modifier.verticalScroll(rememberScrollState(), true)) { - FlowRow( - mainAxisSpacing = PaddingDefaults.Small, - crossAxisSpacing = PaddingDefaults.Small - ) { - Chip( - stringResource(R.string.search_pharmacies_filter_open_now), - closable = false, - checked = filter.openNow - ) { - onClickChip( - filter.copy( - openNow = it, - onlineService = false - ) - ) - } - Chip( - stringResource(R.string.search_pharmacies_filter_ready), - closable = false, - checked = filter.ready - ) { - onClickChip( - filter.copy( - ready = it, - deliveryService = if (!it) false else filter.deliveryService, - onlineService = if (!it) false else filter.onlineService - ) - ) - } - Chip( - stringResource(R.string.search_pharmacies_filter_delivery_service), - closable = false, - checked = filter.deliveryService - ) { - onClickChip( - filter.copy( - deliveryService = it, - ready = true, - onlineService = false - ) - ) - } - Chip( - stringResource(R.string.search_pharmacies_filter_online_service), - closable = false, - checked = filter.onlineService - ) { - onClickChip( - filter.copy( - onlineService = it, - ready = true, - deliveryService = false, - openNow = false - ) - ) - } - } - } - } - SpacerXLarge() - } -} - -@Composable -private fun PharmacyResultCard( - modifier: Modifier, - pharmacy: PharmacyUseCaseData.Pharmacy, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .clickable(onClick = onClick) - .then(modifier) - ) { - val distanceTxt = pharmacy.distance?.let { distance -> - formattedDistance(distance) - } - - Column(modifier = Modifier.weight(1f)) { - PharmacyName(pharmacy.name, pharmacy.ready) - - Text( - pharmacy.address ?: "", - style = AppTheme.typography.body2l, - modifier = Modifier - - ) - - val pharmacyLocalServices = pharmacy.provides.first() - val now = OffsetDateTime.now() - - if (pharmacyLocalServices.isOpenAt(now)) { - val text = if (pharmacyLocalServices.isAllDayOpen(now.dayOfWeek)) { - stringResource(R.string.search_pharmacy_continuous_open) - } else { - stringResource( - R.string.search_pharmacy_open_until, - requireNotNull(pharmacyLocalServices.openUntil(now)).toString() - ) - } - Text( - text, - style = AppTheme.typography.subtitle2l, - color = AppTheme.colors.green600 - ) - } else { - val text = - pharmacyLocalServices.opensAt(now)?.let { - stringResource( - R.string.search_pharmacy_opens_at, - it.toString() - ) - } - if (text != null) { - Text( - text, - style = AppTheme.typography.subtitle2l, - color = AppTheme.colors.yellow600 - ) - } - } - } - - SpacerMedium() - - if (distanceTxt != null) { - Text( - distanceTxt, - style = AppTheme.typography.body2l, - modifier = Modifier - .align(Alignment.CenterVertically), - textAlign = TextAlign.End - ) - } - Icon( - Icons.Rounded.KeyboardArrowRight, null, - tint = AppTheme.colors.neutral400, - modifier = Modifier - .size(24.dp) - .align(Alignment.CenterVertically) - ) - } -} - -private fun formattedDistance(distanceInMeters: Double): String { - val f = DecimalFormat() - return if (distanceInMeters < 1000) { - f.maximumFractionDigits = 0 - f.format(distanceInMeters).toString() + " m" - } else { - f.maximumFractionDigits = 1 - f.format(distanceInMeters / 1000).toString() + " km" - } -} - -@Composable -private fun PharmacyName(name: String, showReadyFlag: Boolean) { - val txt = buildAnnotatedString { - append(name) - if (showReadyFlag) { - append(" ") - appendInlineContent("ready", "ready") - } - } - val c = mapOf( - "ready" to InlineTextContent( - Placeholder( - width = 0.em, - height = 0.em, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter - ) - ) { - ReadyFlag() - } - ) - DynamicText( - txt, - style = MaterialTheme.typography.subtitle1, - inlineContent = c - ) -} - -@Preview -@Composable -private fun PharmacyNamePreview() { - AppTheme { - PharmacyName("Some Pharmacy Name", true) - } -} - -@Composable -fun ReadyFlag(modifier: Modifier = Modifier) { - with(LocalDensity.current) { - val style = MaterialTheme.typography.caption - val fontSize = style.fontSize.toDp() - val space = fontSize / 3 - - Row( - modifier = modifier - .background(color = AppTheme.colors.primary100, shape = RoundedCornerShape(8.dp)) - .wrapContentSize() - .padding(2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.width(space)) - Icon( - painterResource(R.drawable.ic_logo_outlined), - contentDescription = null, - tint = AppTheme.colors.primary500, - modifier = Modifier.size(fontSize * 1.5f) - ) - Spacer(modifier = Modifier.width(space)) - Text( - stringResource(R.string.search_pharmacy_ready_flag), - style = style, - color = AppTheme.colors.primary900 - ) - Spacer(modifier = Modifier.width(space)) - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenNavigationComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenNavigationComponents.kt index fb66aea3..220cc92d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenNavigationComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenNavigationComponents.kt @@ -18,111 +18,258 @@ package de.gematik.ti.erp.app.pharmacy.ui +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.with import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import de.gematik.ti.erp.app.mainscreen.ui.ActionEvent +import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens +import de.gematik.ti.erp.app.mainscreen.ui.MainScreenViewModel import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyNavigationScreens -import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData -import de.gematik.ti.erp.app.tracking.TrackNavigationChanges +import de.gematik.ti.erp.app.analytics.TrackNavigationChanges import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.navigationModeState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel +private const val AnimationOffset = 9 + +private enum class PharmacyOverviewScreen { + Overview, + List +} + +@Suppress("LongMethod") +@OptIn(ExperimentalAnimationApi::class) @Composable -fun PharmacySearchScreenWithNavigation( - taskIds: List, +fun PharmacyNavigation( mainNavController: NavController, - viewModel: PharmacySearchViewModel = hiltViewModel() + mainScreenVM: MainScreenViewModel ) { - val navController = rememberNavController() - val selectedPharmacy = rememberSaveable { - mutableStateOf(null) + val viewModel by rememberViewModel() + val scope = rememberCoroutineScope() + val pharmacySearchController = rememberPharmacySearchController() + var searchFilter by remember(pharmacySearchController.searchState.filter) { + mutableStateOf(pharmacySearchController.searchState.filter) + } + var screen by remember { mutableStateOf(PharmacyOverviewScreen.Overview) } + var showNoLocationDialog by remember { mutableStateOf(false) } + + val searchAgainFn = { nearBy: Boolean -> + scope.launch { + pharmacySearchController.search( + name = "", + filter = searchFilter.copy(nearBy = nearBy) + ) + } } + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + if (permissions.values.any { it }) { + searchAgainFn(true) + } else { + showNoLocationDialog = true + } + } + + if (showNoLocationDialog) { + NoLocationDialog( + onAccept = { + searchAgainFn(false) + showNoLocationDialog = false + } + ) + } + + var showNoLocationServicesDialog by remember { mutableStateOf(false) } + if (showNoLocationServicesDialog) { + NoLocationServicesDialog( + onClose = { + searchAgainFn(false) + showNoLocationServicesDialog = false + } + ) + } + + val navController = rememberNavController() val navigationMode by navController.navigationModeState(PharmacyNavigationScreens.SearchResults.route) + val startDestination = PharmacyNavigationScreens.StartSearch.route TrackNavigationChanges(navController) + val hasRedeemableTasks by produceState(false) { + viewModel.hasRedeemableTasks().collect { value = it } + } + + val handleSearchResultFn = { searchResult: PharmacySearchController.SearchQueryResult -> + when (searchResult) { + PharmacySearchController.SearchQueryResult.NoLocationPermission -> { + locationPermissionLauncher.launch(locationPermissions) + } + PharmacySearchController.SearchQueryResult.NoLocationServicesEnabled -> { + showNoLocationServicesDialog = true + } + + else -> {} + } + } + NavHost( navController, - startDestination = PharmacyNavigationScreens.SearchResults.route + startDestination = startDestination ) { - composable(PharmacyNavigationScreens.SearchResults.route) { - NavigationAnimation(mode = navigationMode) { - PharmacySearchScreen( - mainNavController = mainNavController, - navController = navController, - selectedPharmacy, - viewModel, - ) + composable(PharmacyNavigationScreens.StartSearch.route) { + BackHandler(screen == PharmacyOverviewScreen.List) { + screen = PharmacyOverviewScreen.Overview } - } - composable(PharmacyNavigationScreens.PharmacyDetails.route) { - selectedPharmacy.value?.let { pharmacy -> - NavigationAnimation(mode = navigationMode) { - PharmacyDetailsScreen( - navController, - pharmacy, - showRedeemOptions = !taskIds.isEmpty() - ) + + NavigationAnimation(mode = navigationMode) { + AnimatedContent( + targetState = screen, + transitionSpec = { + if (screen != PharmacyOverviewScreen.Overview) { + slideInVertically(initialOffsetY = { it / AnimationOffset }) + fadeIn() with + fadeOut() + } else { + fadeIn(tween(durationMillis = 550)) with fadeOut(tween(durationMillis = 550)) + } + } + ) { + when (it) { + PharmacyOverviewScreen.Overview -> { + PharmacyOverviewScreen( + onBack = { mainNavController.popBackStack() }, + onFilterChange = { searchFilter = it }, + filter = searchFilter, + onStartSearch = { + scope.launch { + screen = PharmacyOverviewScreen.List + handleSearchResultFn( + pharmacySearchController.search(name = "", filter = searchFilter) + ) + } + }, + onShowMaps = { + scope.launch(Dispatchers.Main) { + navController.navigate(PharmacyNavigationScreens.Maps.path()) + handleSearchResultFn( + pharmacySearchController.search( + name = "", + filter = searchFilter.copy(nearBy = true, ready = true) + ) + ) + } + }, + onSelectPharmacy = { + scope.launch(Dispatchers.Main) { + viewModel.onSelectPharmacy(it) + navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) + } + } + ) + } + + PharmacyOverviewScreen.List -> { + PharmacySearchResultScreen( + pharmacySearchController = pharmacySearchController, + onBack = { screen = PharmacyOverviewScreen.Overview }, + onClickMaps = { + scope.launch(Dispatchers.Main) { + navController.navigate(PharmacyNavigationScreens.Maps.path()) + handleSearchResultFn( + pharmacySearchController.search( + name = "", + filter = searchFilter.copy(nearBy = true) + ) + ) + } + }, + onSelectPharmacy = { + scope.launch(Dispatchers.Main) { + viewModel.onSelectPharmacy(it) + navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) + } + } + ) + } + } } } } - composable(PharmacyNavigationScreens.ReserveInPharmacy.route) { - selectedPharmacy.value?.let { pharmacy -> - NavigationAnimation(mode = navigationMode) { - ReserveForPickupInPharmacy( - navController = navController, - viewModel, - taskIds, - pharmacy.name, - pharmacy.telematikId - ) - } + composable(PharmacyNavigationScreens.Maps.route) { + NavigationAnimation(mode = navigationMode) { + MapsOverview( + pharmacySearchController = pharmacySearchController, + hasRedeemableTasks = hasRedeemableTasks, + onBack = { navController.popBackStack() }, + onSelectPharmacy = { pharmacy, orderOption -> + scope.launch { + viewModel.onSelectPharmacy(pharmacy) + if (orderOption != null) { + viewModel.onSelectOrderOption(orderOption) + navController.navigate(PharmacyNavigationScreens.OrderPrescription.path()) + } else { + navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) + } + } + } + ) } } - composable(PharmacyNavigationScreens.CourierDelivery.route) { - selectedPharmacy.value?.let { pharmacy -> - NavigationAnimation(mode = navigationMode) { - CourierDelivery( - navigation = navController, - viewModel, - taskIds, - pharmacy.name, - pharmacy.telematikId, - pharmacy.contacts.phone - ) - } + composable(PharmacyNavigationScreens.PharmacyDetails.route) { + NavigationAnimation(mode = navigationMode) { + PharmacyDetailsScreen( + navController = navController, + viewModel = viewModel, + hasRedeemableTasks = hasRedeemableTasks, + onClickFavoriteStar = { pharmacy, markAsFavorite -> + scope.launch { + if (markAsFavorite) { + viewModel.saveOrUpdateFavoritePharmacy(pharmacy) + } else { + viewModel.deleteFavoritePharmacy(pharmacy) + } + } + } + ) } } - composable(PharmacyNavigationScreens.MailDelivery.route) { - selectedPharmacy.value?.let { pharmacy -> - NavigationAnimation(mode = navigationMode) { - MailDelivery( - navigation = navController, - viewModel, - taskIds, - pharmacy.name, - pharmacy.telematikId - ) - } + composable(PharmacyNavigationScreens.OrderPrescription.route) { + NavigationAnimation(mode = navigationMode) { + PharmacyOrderScreen( + navController = navController, + viewModel = viewModel, + onSuccessfullyOrdered = { + mainScreenVM.onSuccessfullyOrdered(ActionEvent.ReturnFromPharmacyOrder(it)) + mainNavController.popBackStack(MainNavigationScreens.Prescriptions.path(), false) + } + ) } } - composable( - PharmacyNavigationScreens.UploadStatus.route, - PharmacyNavigationScreens.UploadStatus.arguments - ) { - val redeemOption = remember { requireNotNull(it.arguments?.getInt("redeemOption")) } + composable(PharmacyNavigationScreens.EditShippingContact.route) { NavigationAnimation(mode = navigationMode) { - RedeemOnlineSuccess( - redeemOption, - mainNavController + EditShippingContactScreen( + navController, + viewModel ) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModel.kt index b84e5c1b..8944b68a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModel.kt @@ -19,271 +19,138 @@ package de.gematik.ti.erp.app.pharmacy.ui import android.Manifest -import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import androidx.paging.filter -import androidx.paging.insertHeaderItem -import androidx.paging.map -import com.google.android.gms.location.LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY -import com.google.android.gms.location.LocationServices -import com.google.android.gms.tasks.CancellationTokenSource -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.common.usecase.HintUseCase -import de.gematik.ti.erp.app.common.usecase.model.PharmacyScreenHintEnableLocation -import de.gematik.ti.erp.app.pharmacy.repository.model.DeliveryPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.Location -import de.gematik.ti.erp.app.pharmacy.repository.model.isOpenAt +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.activeProfile import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import java.time.OffsetDateTime -import javax.inject.Inject - -private const val waitForLocationUpdate = 5000L sealed class PharmacySearchUi { class Pharmacy(val pharmacy: PharmacyUseCaseData.Pharmacy) : PharmacySearchUi() - object LocationHint : PharmacySearchUi() } -@HiltViewModel -class PharmacySearchViewModel @Inject constructor( - @ApplicationContext - private val context: Context, +class PharmacySearchViewModel( private val useCase: PharmacySearchUseCase, - private val hintUseCase: HintUseCase, - private val dispatcher: DispatchProvider + private val pharmacyOverviewUseCase: PharmacyOverviewUseCase, + private val profilesUseCase: ProfilesUseCase, + private val dispatchers: DispatchProvider ) : ViewModel() { - private val searchChannel = Channel() - private var searchState = MutableStateFlow(null) @OptIn(ExperimentalCoroutinesApi::class) - val pharmacySearchFlow: Flow> = - searchChannel - .receiveAsFlow() - .onEach { - // if we receive an empty list as the first page and the last searchPagingItems state was already populated with results, - // the continues loading won't work; this short timeout is an ugly workaround to this issue - delay(100) - searchState.value = it - - if (it.locationMode is PharmacyUseCaseData.LocationMode.Enabled) { - cancelLocationHint() - } - } - .flatMapLatest { searchData -> - useCase.searchPharmacies(searchData) - .map { pagingData -> - if (searchData.locationMode is PharmacyUseCaseData.LocationMode.Enabled) { - pagingData.map { - it.copy( - distance = it.location?.minus(searchData.locationMode.location) - ) - } - } else { - pagingData - }.filter { pharmacy -> - if (searchData.filter.deliveryService) { - when { - searchData.filter.deliveryService && pharmacy.provides.any { it is DeliveryPharmacyService } -> true - else -> false - } - } else { - true - } - }.filter { - if (searchData.filter.openNow) { - when { - it.openingHours == null -> false - it.openingHours.isOpenAt(OffsetDateTime.now()) -> true - else -> false - } - } else { - true - } - }.map { - PharmacySearchUi.Pharmacy(it) - }.insertHeaderItem(item = PharmacySearchUi.LocationHint) - } - .cachedIn(viewModelScope) - }.shareIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - 1 - ) + fun hasRedeemableTasks(): Flow = + profilesUseCase.profiles.map { it.activeProfile() }.flatMapLatest { + useCase.hasRedeemableTasks(it.id) + } - @SuppressLint("MissingPermission") - @OptIn(ExperimentalCoroutinesApi::class) - private suspend fun queryLocation(): Location? = withTimeoutOrNull(waitForLocationUpdate) { - suspendCancellableCoroutine { continuation -> - val cancelTokenSource = CancellationTokenSource() + private data class NavState( + // orders identified by their taskId + val unSelectedPrescriptions: Set, + val selectedOrderOption: PharmacyScreenData.OrderOption?, + val selectedPharmacy: PharmacyUseCaseData.Pharmacy?, + val isMarkedAsFavorite: Boolean + ) - continuation.invokeOnCancellation { cancelTokenSource.cancel() } + private val navState = MutableStateFlow( + NavState( + unSelectedPrescriptions = setOf(), + selectedOrderOption = null, + selectedPharmacy = null, + isMarkedAsFavorite = false + ) + ) - LocationServices - .getFusedLocationProviderClient(context) - .getCurrentLocation(PRIORITY_BALANCED_POWER_ACCURACY, cancelTokenSource.token) - .addOnFailureListener { - continuation.cancel() - } - .addOnSuccessListener { - continuation.resume(Location(longitude = it.longitude, latitude = it.latitude), null) - } + fun detailScreenState() = navState.transform { + if (it.selectedPharmacy != null) { + emit( + PharmacyScreenData.DetailScreenState( + it.selectedPharmacy, + it.isMarkedAsFavorite + ) + ) } } - - init { - viewModelScope.launch(dispatcher.unconfined()) { - val searchData = useCase.previousSearch.map { - it.copy(locationMode = if (anyLocationPermissionGranted(context)) it.locationMode else PharmacyUseCaseData.LocationMode.Disabled) - }.first() - - searchPharmacies( - searchData.name, - searchData.filter, - searchData.locationMode is PharmacyUseCaseData.LocationMode.EnabledWithoutPosition - ) + suspend fun saveOrUpdateFavoritePharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + viewModelScope.launch(dispatchers.IO) { + pharmacyOverviewUseCase.saveOrUpdateFavoritePharmacy(pharmacy) } } - /** - * Returns `true` if a position couldn't be queried. - */ - suspend fun searchPharmacies( - name: String, - filter: PharmacyUseCaseData.Filter, - withLocationEnabled: Boolean - ): Boolean = withContext(dispatcher.unconfined()) { - val locationMode = if (withLocationEnabled) { - queryLocation() - ?.let { PharmacyUseCaseData.LocationMode.Enabled(it) } - ?: PharmacyUseCaseData.LocationMode.Disabled - } else { - PharmacyUseCaseData.LocationMode.Disabled + suspend fun deleteFavoritePharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + viewModelScope.launch(dispatchers.IO) { + pharmacyOverviewUseCase.deleteFavoritePharmacy(pharmacy) } - searchChannel.send(PharmacyUseCaseData.SearchData(name, filter, locationMode)) + } - withLocationEnabled && locationMode is PharmacyUseCaseData.LocationMode.Disabled + suspend fun onSelectPharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + val isMarkedAsFavorite = pharmacyOverviewUseCase.isPharmacyInFavorites(pharmacy.telematikId) + navState.update { + it.copy(selectedPharmacy = pharmacy, isMarkedAsFavorite = isMarkedAsFavorite) + } } @OptIn(ExperimentalCoroutinesApi::class) - fun screenState(): Flow = flow { - emitAll( - combine(hintUseCase.cancelledHints, searchState.filterNotNull()) { cancelledHints, search -> - PharmacyUseCaseData.State( - search = search, - PharmacyScreenHintEnableLocation !in cancelledHints + fun orderScreenState(): Flow = + profilesUseCase.profiles.map { it.activeProfile() }.flatMapLatest { activeProfile -> + combine( + useCase.prescriptionDetailsForOrdering(activeProfile.id), + navState.filter { it.selectedPharmacy != null && it.selectedOrderOption != null } + ) { state, navState -> + PharmacyScreenData.OrderScreenState( + activeProfile = activeProfile, + contact = state.contact, + prescriptions = state.prescriptions.map { + Pair(it, it.taskId !in navState.unSelectedPrescriptions) + }, + selectedPharmacy = navState.selectedPharmacy!!, + orderOption = navState.selectedOrderOption!! ) } - ) - } + } - fun cancelLocationHint() { - hintUseCase.cancelHint(PharmacyScreenHintEnableLocation) + fun onSelectOrderOption(option: PharmacyScreenData.OrderOption) { + navState.update { + it.copy(selectedOrderOption = option) + } } - @Immutable - data class RedeemUIState( - val loading: Boolean = false, - val success: Boolean = false, - val error: Boolean = false, - val fabState: Boolean = false - ) - - var uiState by mutableStateOf(RedeemUIState()) - private val orders = mutableSetOf() - - fun fetchSelectedOrders(taskIds: List): Flow> { - return useCase.prescriptionDetailsForOrdering(*taskIds.toTypedArray()) - .onStart { updateUIState(loading = true, fabState = false) } - .onCompletion { updateUIState(loading = false, fabState = orders.isNotEmpty()) } - .map { - it.map { order -> - order.selected = orders.contains(order) - order - } - } + fun onSelectOrder(order: PharmacyUseCaseData.PrescriptionOrder) { + navState.update { + it.copy(unSelectedPrescriptions = it.unSelectedPrescriptions - order.taskId) + } } - fun toggleOrder(order: UIPrescriptionOrder): Boolean { - order.selected = !order.selected - if (order.selected) { - orders.add(order) - } else { - orders.remove(order) + fun onDeselectOrder(order: PharmacyUseCaseData.PrescriptionOrder) { + navState.update { + it.copy(unSelectedPrescriptions = it.unSelectedPrescriptions + order.taskId) } - updateUIState(fabState = orders.isNotEmpty()) - return order.selected } - fun triggerOrderInPharmacy(telematikId: String, redeemOption: RemoteRedeemOption) { - viewModelScope.launch(dispatcher.io()) { - updateUIState(loading = true, fabState = false) - var uploadStatus = false - for (order in orders) { - val deferred = async { - useCase.redeemPrescription( - redeemOption, - order, - telematikId, - ) - } - uploadStatus = deferred.await() is Result.Success - } - updateUIState(loading = false, fabState = true) - if (uploadStatus) { - updateUIState(success = true, fabState = false) - } else { - updateUIState(error = true, fabState = true) - } + fun onSaveContact(contact: PharmacyUseCaseData.ShippingContact) { + viewModelScope.launch { + useCase.saveShippingContact(contact) } } - private fun updateUIState( - loading: Boolean = false, - success: Boolean = false, - error: Boolean = false, - fabState: Boolean = false - ) { - uiState = - uiState.copy(loading = loading, success = success, error = error, fabState = fabState) + suspend fun isPharmacyInFavorites(telematikId: String): Boolean = withContext(dispatchers.IO) { + pharmacyOverviewUseCase.isPharmacyInFavorites(telematikId) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemErrorMessage.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemErrorMessage.kt new file mode 100644 index 00000000..d3233b24 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemErrorMessage.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.content.Context +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.ui.GenerellErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState + +fun redeemErrorMessage(context: Context, redeemState: PrescriptionServiceErrorState): String? = + when (redeemState) { + GenerellErrorState.NetworkNotAvailable -> + context.getString(R.string.error_message_network_not_available) + is GenerellErrorState.ServerCommunicationFailedWhileRefreshing -> + context.getString(R.string.error_message_server_communication_failed).format(redeemState.code) + GenerellErrorState.FatalTruststoreState -> + context.getString(R.string.error_message_vau_error) + is RedeemPrescriptionsController.State.Error.Unknown -> + context.getString(R.string.redeem_online_error_uploading) + is GenerellErrorState.NoneEnrolled -> + context.getString(R.string.no_auth_enrolled) + else -> null + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemOnlineSuccessComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemOnlineSuccessComponents.kt deleted file mode 100644 index cd4705af..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemOnlineSuccessComponents.kt +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.ui - -import android.media.MediaPlayer -import android.view.SurfaceHolder -import android.view.SurfaceView -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar -import androidx.compose.material.Button -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.view.updateLayoutParams -import androidx.navigation.NavController -import com.google.accompanist.insets.statusBarsPadding -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import java.util.Locale - -@Composable -fun RedeemOnlineSuccess( - redeemOption: Int, - mainNavController: NavController -) { - BackHandler { - mainNavController.popBackStack(MainNavigationScreens.Prescriptions.path(), false) - } - Scaffold( - modifier = Modifier.statusBarsPadding(), - bottomBar = { - BottomButton( - navController = mainNavController - ) - } - ) { - Box( - modifier = Modifier - .padding(it) - .verticalScroll(rememberScrollState()) - ) { - Box(modifier = Modifier.padding(PaddingDefaults.Medium)) { - when (redeemOption) { - 0 -> PickupAndCourier( - R.raw.animation_local, - stringResource(id = R.string.redeem_online_local_success_message) - ) - 1 -> MailDelivery(R.raw.animation_mail) - 2 -> PickupAndCourier( - R.raw.animation_courier, - stringResource(id = R.string.redeem_online_courier_success_message) - ) - } - } - } - } -} - -@Composable -fun PickupAndCourier(source: Int, message: String) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .padding(bottom = 24.dp) - .clip(CircleShape) - ) { - VideoContent(source) - } - Text( - text = stringResource(id = R.string.redeem_online_success_header), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center - ) - Text( - text = message, - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.body1, - textAlign = TextAlign.Center - ) - } -} - -@Composable -fun MailDelivery(source: Int) { - Column { - Box( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 24.dp) - .clip(CircleShape) - ) { - VideoContent(source) - } - Text( - text = stringResource(id = R.string.redeem_online_mail_success_header), - modifier = Modifier - .align(Alignment.CenterHorizontally), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center - ) - - Spacer16() - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - NumberedCircle(nr = 1, tint = AppTheme.colors.neutral600) - Spacer16() - Text( - text = stringResource(id = R.string.redeem_online_mail_success_step1), - modifier = Modifier.padding(end = 16.dp), - style = MaterialTheme.typography.body1 - ) - } - Spacer16() - Row { - NumberedCircle(nr = 2, tint = AppTheme.colors.neutral600) - Spacer16() - Text( - text = stringResource(id = R.string.redeem_online_mail_success_step2), - modifier = Modifier.padding(end = 16.dp), - style = MaterialTheme.typography.body1 - ) - } - Spacer16() - Row { - NumberedCircle(nr = 3, tint = AppTheme.colors.neutral600) - Spacer16() - Text( - text = stringResource(id = R.string.redeem_online_mail_success_step3), - modifier = Modifier.padding(end = 16.dp), - style = MaterialTheme.typography.body1 - ) - } - } -} - -@OptIn(ExperimentalStdlibApi::class) -@Composable -fun BottomButton(modifier: Modifier = Modifier, navController: NavController) { - BottomAppBar(modifier = modifier, backgroundColor = MaterialTheme.colors.surface) { - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { navController.popBackStack(MainNavigationScreens.Prescriptions.path(), false) } - ) { - Text(text = stringResource(id = R.string.redeem_online_back_home).uppercase(Locale.getDefault())) - } - SpacerSmall() - } -} - -@Composable -fun VideoContent(source: Int) { - val context = LocalContext.current - var aspect by remember { mutableStateOf(1.0f) } - val player = remember { - MediaPlayer().apply { - - setDataSource(context.resources.openRawResourceFd(source)) - setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) - - setOnVideoSizeChangedListener { _, width, height -> - aspect = width.toFloat() / height - } - - isLooping = true - } - } - - val surfaceCallback = remember { - object : SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - player.prepare() - player.start() - player.setDisplay(holder) - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} - - override fun surfaceDestroyed(holder: SurfaceHolder) { - player.stop() - player.setDisplay(null) - } - } - } - - var size by remember { mutableStateOf(IntSize(800, 800)) } - AndroidView( - factory = { ctx -> - val view = SurfaceView(ctx) - - view.holder.addCallback(surfaceCallback) - - view - }, - modifier = Modifier - .aspectRatio(aspect) - .onSizeChanged { - size = it - } - ) { - it.updateLayoutParams { - this.height = size.width - this.width = size.height - } - } -} - -@Composable -private fun NumberedCircle(nr: Int, tint: Color, modifier: Modifier = Modifier) = - Icon( - when (nr) { - 1 -> painterResource(R.drawable.ic_step_1) - 2 -> painterResource(R.drawable.ic_step_2) - 3 -> painterResource(R.drawable.ic_step_3) - else -> painterResource(R.drawable.ic_step_1) - }, - null, modifier = modifier, tint = tint - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemPrescriptionsController.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemPrescriptionsController.kt new file mode 100644 index 00000000..7d6f8248 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/RedeemPrescriptionsController.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.cardwall.mini.ui.Authenticator +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.prescription.ui.catchAndTransformRemoteExceptions +import de.gematik.ti.erp.app.prescription.ui.retryWithAuthenticator +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.kodein.di.compose.rememberInstance +import java.util.UUID + +@Stable +class RedeemPrescriptionsController( + private val searchUseCase: PharmacySearchUseCase, + private val overviewUseCase: PharmacyOverviewUseCase, + private val dispatchers: DispatchProvider, + private val authenticator: Authenticator +) { + sealed interface State : PrescriptionServiceState { + class Ordered(val orderId: String) : State + + sealed interface Error : State, PrescriptionServiceErrorState { + object Unknown : Error + } + } + + suspend fun orderPrescriptions( + profileId: ProfileIdentifier, + orderId: UUID, + prescriptions: List, + redeemOption: PharmacyScreenData.OrderOption, + pharmacy: PharmacyUseCaseData.Pharmacy, + contact: PharmacyUseCaseData.ShippingContact + ): PrescriptionServiceState = + orderPrescriptionsFlow( + profileId = profileId, + orderId = orderId, + prescriptions = prescriptions, + redeemOption = redeemOption, + pharmacy = pharmacy, + contact = contact + ).cancellable().first() + + private fun orderPrescriptionsFlow( + profileId: ProfileIdentifier, + orderId: UUID, + prescriptions: List, + redeemOption: PharmacyScreenData.OrderOption, + pharmacy: PharmacyUseCaseData.Pharmacy, + contact: PharmacyUseCaseData.ShippingContact + ) = + flow { + withContext(dispatchers.IO) { + val result = prescriptions + .map { prescription -> + async { + searchUseCase.redeemPrescription( + orderId = orderId, + profileId = profileId, + redeemOption = when (redeemOption) { + PharmacyScreenData.OrderOption.ReserveInPharmacy -> RemoteRedeemOption.Local + PharmacyScreenData.OrderOption.CourierDelivery -> RemoteRedeemOption.Delivery + PharmacyScreenData.OrderOption.MailDelivery -> RemoteRedeemOption.Shipment + }, + order = prescription, + contact = contact, + pharmacyTelematikId = pharmacy.telematikId + ) + } + } + .awaitAll() + .find { it.isFailure } + overviewUseCase.saveOrUpdateUsedPharmacies(pharmacy) + result?.let { Result.failure(it.exceptionOrNull()!!) } ?: Result.success(Unit) + }.also { + emit(it) + } + }.map { result -> + result.fold( + onSuccess = { + State.Ordered(orderId.toString()) + }, + onFailure = { + throw it + } + ) + } + .retryWithAuthenticator( + isUserAction = true, + authenticate = authenticator.authenticateForPrescriptions(profileId) + ) + .catchAndTransformRemoteExceptions() + .catch { + // TODO: remove for better error handling + emit(State.Error.Unknown) + } + .flowOn(dispatchers.IO) +} + +@Composable +fun rememberRedeemPrescriptionsController(): RedeemPrescriptionsController { + val searchUseCase by rememberInstance() + val overviewUseCase by rememberInstance() + val dispatchers by rememberInstance() + val authenticator = LocalAuthenticator.current + return remember { + RedeemPrescriptionsController( + searchUseCase = searchUseCase, + overviewUseCase = overviewUseCase, + dispatchers = dispatchers, + authenticator = authenticator + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/ReserveForPickupComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/ReserveForPickupComponents.kt deleted file mode 100644 index ac086150..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/ReserveForPickupComponents.kt +++ /dev/null @@ -1,417 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.ui - -import androidx.compose.animation.core.Animatable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ContentAlpha -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Snackbar -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.SnackbarResult -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.contentColorFor -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FileUpload -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.RadioButtonUnchecked -import androidx.compose.material.rememberScaffoldState -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyNavigationScreens -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption -import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption.Local -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog -import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer4 -import de.gematik.ti.erp.app.utils.compose.Spacer48 -import de.gematik.ti.erp.app.utils.compose.annotatedStringResource -import kotlinx.coroutines.flow.collect -import java.util.Locale - -@Composable -fun ReserveForPickupInPharmacy( - navController: NavController, - viewModel: PharmacySearchViewModel, - taskIds: List, - pharmacyName: String, - telematikId: String -) { - val prescriptions by produceState(initialValue = listOf()) { - taskIds.takeIf { it.isNotEmpty() }?.let { ids -> - viewModel.fetchSelectedOrders(ids).collect { value = it } - } - } - val header = stringResource(id = R.string.reserve_header) - val alpha = if (viewModel.uiState.loading) ContentAlpha.medium else ContentAlpha.high - HeaderWithScaffold( - navController = navController, - viewModel = viewModel, - telematikId = telematikId, - redeemOption = Local, - header = header, - uiState = viewModel.uiState - ) { - item { - DescriptionHeader(pharmacyName = pharmacyName) - Spacer48() - } - item { - PrescriptionHeader(alpha) - Spacer16() - } - items(items = prescriptions) { prescription -> - PrescriptionOrder( - prescription, - toggleContentDescription = "contentDescription", - alpha, - viewModel::toggleOrder, - ) - } - item { - Spacer48() - } - } -} - -@Composable -fun HeaderWithScaffold( - navController: NavController, - viewModel: PharmacySearchViewModel, - telematikId: String, - header: String, - redeemOption: RemoteRedeemOption, - uiState: PharmacySearchViewModel.RedeemUIState, - items: LazyListScope.() -> Unit -) { - LaunchedEffect(uiState.success) { - if (uiState.success) { - navController.navigate(PharmacyNavigationScreens.UploadStatus.path(redeemOption.ordinal)) - } - } - val message = stringResource(id = R.string.redeem_online_error_uploading) - val actionLabel = stringResource(id = R.string.redeem_online_error_retry_label) - - val scaffoldState = rememberScaffoldState() - - if (uiState.error) { - LaunchedEffect(scaffoldState.snackbarHostState) { - val result = scaffoldState.snackbarHostState.showSnackbar( - message = message, - actionLabel = actionLabel - ) - if (result == SnackbarResult.ActionPerformed) { - viewModel.triggerOrderInPharmacy(telematikId, redeemOption) - } - } - } - - Scaffold( - scaffoldState = scaffoldState, - snackbarHost = { - ErrorSnackBar(snackBarHostState = it) - }, - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = header, - onBack = { navController.popBackStack() } - ) - }, - floatingActionButton = { - RedeemFab(uiState.fabState, Icons.Default.FileUpload) { - viewModel.triggerOrderInPharmacy( - telematikId, redeemOption - ) - } - } - ) { innerPadding -> - Box(modifier = Modifier.fillMaxHeight()) { - LazyColumn( - modifier = Modifier - .padding(innerPadding) - .padding(16.dp), - content = items, - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) - ) - LoadingIndicator(visible = uiState.loading, modifier = Modifier.align(Alignment.Center)) - } - } -} - -@Composable -fun ErrorSnackBar( - snackBarHostState: SnackbarHostState, - modifier: Modifier = Modifier, -) { - SnackbarHost( - hostState = snackBarHostState, - snackbar = { data -> - Snackbar( - backgroundColor = AppTheme.colors.neutral900, - modifier = Modifier.padding(16.dp), - action = { - TextButton( - onClick = { snackBarHostState.currentSnackbarData?.performAction() } - ) { - Text( - text = data.actionLabel ?: "", - color = AppTheme.colors.primary600 - ) - } - } - ) { - Text( - text = snackBarHostState.currentSnackbarData?.message ?: "", - color = AppTheme.colors.neutral300 - ) - } - }, - modifier = modifier - ) -} - -@Composable -fun DescriptionHeader(pharmacyName: String) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = annotatedStringResource( - id = R.string.pharm_reserve_subheader, - buildAnnotatedString { - pushStyle(SpanStyle(color = AppTheme.colors.neutral900)) - append(pharmacyName) - } - ), - textAlign = TextAlign.Center, - style = AppTheme.typography.subtitle1l - ) -} - -@Composable -fun PrescriptionHeader(contentAlpha: Float = 1f) { - CompositionLocalProvider(LocalContentAlpha provides contentAlpha) { - Text( - text = stringResource(id = R.string.pharm_reserve_prescriptions), - style = MaterialTheme.typography.h6, - ) - } -} - -@Composable -fun LoadingIndicator(visible: Boolean, modifier: Modifier = Modifier) { - if (visible) { - CircularProgressIndicator(modifier = modifier) - } -} - -@Composable -fun PrescriptionOrder( - order: UIPrescriptionOrder, - toggleContentDescription: String, - contentAlpha: Float = 1f, - onAddOrder: (UIPrescriptionOrder) -> Boolean -) { - var selected by remember { mutableStateOf(order.selected) } - CompositionLocalProvider(LocalContentAlpha provides contentAlpha) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - - val alpha = remember { Animatable(0.0f) } - LaunchedEffect(selected) { - if (selected) { - alpha.animateTo(1.0f) - } else { - alpha.animateTo(0.0f) - } - } - - Column( - modifier = Modifier - .padding(end = 16.dp) - .weight(1f) - ) { - Text(text = order.title ?: "", style = MaterialTheme.typography.subtitle1) - Spacer4() - if (order.substitutionsAllowed) { - Text( - text = stringResource(id = R.string.pres_detail_aut_idem_info), - style = AppTheme.typography.body2l - ) - } - } - - Box( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(end = 4.dp) - .toggleable( - value = selected, - onValueChange = { - selected = onAddOrder(order) - }, - role = Role.Checkbox, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( - bounded = false, - radius = 16.dp - ) - ) - .semantics { - contentDescription = toggleContentDescription - } - ) { - Icon( - Icons.Rounded.RadioButtonUnchecked, null, - tint = AppTheme.colors.neutral400.copy(alpha = LocalContentAlpha.current) - ) - Icon( - Icons.Rounded.CheckCircle, null, - tint = AppTheme.colors.primary600.copy(alpha = LocalContentAlpha.current), - modifier = Modifier.alpha(alpha.value) - ) - } - } - } -} - -@Composable -fun RedeemFab( - fabState: Boolean, - icon: ImageVector, - onClick: () -> Unit -) { - var dialogVisible by remember { mutableStateOf(false) } - if (dialogVisible) { - - RedeemAlertDialog( - onRedeem = { - onClick() - dialogVisible = false - }, - onDismissRequest = { - dialogVisible = false - } - ) - } - - val backgroundColor = if (fabState) { - MaterialTheme.colors.secondary - } else { - AppTheme.colors.neutral300 - } - - val contentColor = if (fabState) { - contentColorFor(backgroundColor) - } else { - AppTheme.colors.neutral500 - } - - FloatingActionButton( - modifier = Modifier.padding( - rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) - ), - onClick = { - if (fabState) { - dialogVisible = true - } - }, - backgroundColor = backgroundColor, - contentColor = contentColor, - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, contentDescription = "") - Spacer4() - Text(text = stringResource(id = R.string.pharm_redeem).uppercase(Locale.getDefault())) - } - } -} - -@Composable -private fun RedeemAlertDialog( - onDismissRequest: () -> Unit, - onRedeem: () -> Unit -) = - CommonAlertDialog( - header = stringResource(R.string.redeem_online_detail_header), - info = stringResource(R.string.redeem_online_detail_message), - cancelText = stringResource(R.string.redeem_online_no), - actionText = stringResource(R.string.redeem_online_yes), - onCancel = onDismissRequest, - onClickAction = onRedeem - ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/VideoContent.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/VideoContent.kt new file mode 100644 index 00000000..e9eec362 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/VideoContent.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui + +import android.media.MediaPlayer +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.annotation.RawRes +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.updateLayoutParams +import kotlin.math.max + +/** + * [MediaPlayer] backed video composable. + * + * @param aspectRatioOverwrite Prevents the delayed aspect ratio calculation of the video source. Defaults to `null`. + * @param source Android resource + */ +@Composable +fun VideoContent( + modifier: Modifier = Modifier, + aspectRatioOverwrite: Float? = null, + @RawRes source: Int +) { + val context = LocalContext.current + var aspectRatio by remember(aspectRatioOverwrite) { + mutableStateOf(aspectRatioOverwrite ?: 0f) + } + val player = remember(source) { + MediaPlayer().apply { + setDataSource(context.resources.openRawResourceFd(source)) + setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) + + setOnVideoSizeChangedListener { mp, width, height -> + if (aspectRatioOverwrite == null) { + aspectRatio = width / max(1f, height.toFloat()) + } + } + + isLooping = true + } + } + + val surfaceCallback = remember(source) { + object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + player.prepare() + player.start() + player.setDisplay(holder) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + player.stop() + player.setDisplay(null) + } + } + } + + var size by remember { mutableStateOf(IntSize(1, 1)) } + AndroidView( + factory = { ctx -> + val view = SurfaceView(ctx) + + view.holder.addCallback(surfaceCallback) + + view + }, + modifier = modifier + .then( + // prevent irritating large surfaces on first layout calc + if (aspectRatio == 0f) { + Modifier + } else { + Modifier.aspectRatio(aspectRatio) + } + ) + .onSizeChanged { + size = it + } + ) { + it.updateLayoutParams { + this.height = size.width + this.width = size.height + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/Navigation.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/Navigation.kt index 19ed5dc5..38309f1f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/Navigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/Navigation.kt @@ -18,17 +18,13 @@ package de.gematik.ti.erp.app.pharmacy.ui.model -import androidx.navigation.NavType -import androidx.navigation.navArgument import de.gematik.ti.erp.app.Route object PharmacyNavigationScreens { - object SearchResults : Route("PharmacySearchResults") + object StartSearch : Route("StartSearch") + object SearchResults : Route("SearchResults") + object Maps : Route("Maps") object PharmacyDetails : Route("PharmacyDetails") - object ReserveInPharmacy : Route("ReserveInPharmacy") - object CourierDelivery : Route("CourierDelivery") - object MailDelivery : Route("MailDelivery") - object UploadStatus : Route("UploadStatus", navArgument("redeemOption") { type = NavType.IntType }) { - fun path(redeemOption: Int) = path("redeemOption" to redeemOption) - } + object OrderPrescription : Route("OrderPrescription") + object EditShippingContact : Route("EditShippingContact") } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/PharmacyScreenData.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/PharmacyScreenData.kt new file mode 100644 index 00000000..8cfff07e --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/model/PharmacyScreenData.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.ui.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.fhir.model.PharmacyContacts +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData.PrescriptionOrder +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData.ShippingContact +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData + +object PharmacyScreenData { + @Immutable + data class DetailScreenState( + val selectedPharmacy: PharmacyUseCaseData.Pharmacy, + val markedAsFavorite: Boolean + ) + + @Immutable + data class OrderScreenState( + val activeProfile: ProfilesUseCaseData.Profile, + val contact: ShippingContact, + val prescriptions: List>, + val selectedPharmacy: PharmacyUseCaseData.Pharmacy, + val orderOption: OrderOption + ) { + @Stable + fun anySelected() = prescriptions.any { it.second } + } + + @Immutable + enum class OrderOption { + ReserveInPharmacy, + CourierDelivery, + MailDelivery + } + + val defaultOrderState = OrderScreenState( + activeProfile = ProfilesUseCaseData.Profile( + id = "0", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + lastAuthenticated = null, + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage + ), + contact = ShippingContact( + name = "", + line1 = "", + line2 = "", + postalCodeAndCity = "", + telephoneNumber = "", + mail = "", + deliveryInformation = "" + ), + prescriptions = listOf(), + selectedPharmacy = PharmacyUseCaseData.Pharmacy( + name = "", + address = null, + location = null, + distance = null, + contacts = PharmacyContacts(phone = "", mail = "", url = ""), + provides = listOf(), + openingHours = null, + telematikId = "", + ready = false + ), + orderOption = OrderOption.ReserveInPharmacy + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt new file mode 100644 index 00000000..4d2fdbbf --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyDirectRedeemUseCase.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.usecase + +import de.gematik.ti.erp.app.pharmacy.buildDirectPharmacyMessage +import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRepository +import org.bouncycastle.cert.X509CertificateHolder +import java.util.UUID + +class PharmacyDirectRedeemUseCase( + private val repository: PharmacyRepository +) { + + suspend fun redeemPrescription( + url: String, + message: String, + telematikId: String, + recipientCertificates: List, + transactionId: String = UUID.randomUUID().toString() + ): Result = + runCatching { + val asn1Message = buildDirectPharmacyMessage( + message = message, + recipientCertificates = recipientCertificates + ) + + repository.redeemPrescription( + url = url, + message = asn1Message, + pharmacyTelematikId = telematikId, + transactionId = transactionId + ).getOrThrow() + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyMapsUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyMapsUseCase.kt new file mode 100644 index 00000000..de8062cb --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyMapsUseCase.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.usecase + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRepository +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase +import kotlinx.coroutines.withContext + +const val PharmacyMapNextResultsPerPage = 50 +private const val PharmacyMapMaxResults = 120 + +class PharmacyMapsUseCase( + private val repository: PharmacyRepository, + private val settingsUseCase: SettingsUseCase, + private val dispatchers: DispatchProvider +) { + suspend fun searchPharmacies( + searchData: PharmacyUseCaseData.SearchData + ): List = + withContext(dispatchers.IO) { + settingsUseCase.savePharmacySearch( + SettingsData.PharmacySearch( + name = searchData.name, + locationEnabled = searchData.locationMode !is PharmacyUseCaseData.LocationMode.Disabled, + ready = searchData.filter.ready, + deliveryService = searchData.filter.deliveryService, + onlineService = searchData.filter.onlineService, + openNow = searchData.filter.openNow + ) + ) + + val names = searchData.name.split(" ").filter { it.isNotEmpty() } + val locationMode = searchData.locationMode + val filter = run { + val filterMap = mutableMapOf() + if (locationMode is PharmacyUseCaseData.LocationMode.Enabled) { + @Suppress("MagicNumber") + val radiusInKm = locationMode.radiusInMeter.toInt() / 1000 + val loc = locationMode.location + filterMap += "near" to "${loc.latitude}|${loc.longitude}|$radiusInKm|km" + } + if (searchData.filter.ready) { + filterMap += "status" to "active" + } + if (searchData.filter.onlineService) { + filterMap += "type" to "mobl" + } + filterMap + } + + val initialResult = repository.searchPharmacies( + names = names, + filter = filter + ).getOrThrow() + + if (initialResult.bundleResultCount == PharmacyInitialResultsPerPage) { + val pharmacies = initialResult.pharmacies.mapToUseCasePharmacies().toMutableList() + + var offset = initialResult.bundleResultCount + loop@ while (true) { + val result = repository.searchPharmaciesByBundle( + bundleId = initialResult.bundleId, + offset = offset, + count = PharmacyMapNextResultsPerPage + ).getOrThrow() + + if (result.bundleResultCount < PharmacyMapNextResultsPerPage || offset > PharmacyMapMaxResults) { + break@loop + } + + pharmacies += result.pharmacies.mapToUseCasePharmacies() + offset += result.bundleResultCount + } + + pharmacies + } else { + initialResult.pharmacies.mapToUseCasePharmacies() + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyOverviewUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyOverviewUseCase.kt new file mode 100644 index 00000000..a768908e --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacyOverviewUseCase.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy.usecase + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.pharmacy.model.OverviewPharmacyData +import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRepository +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext + +class PharmacyOverviewUseCase( + private val repository: PharmacyRepository, + private val dispatchers: DispatchProvider +) { + fun oftenUsedPharmacies(): Flow> = + repository.loadOftenUsedPharmacies().flowOn(dispatchers.IO) + + fun favoritePharmacies(): Flow> = + repository.loadFavoritePharmacies().flowOn(dispatchers.IO) + + suspend fun saveOrUpdateUsedPharmacies(pharmacy: PharmacyUseCaseData.Pharmacy) { + repository.saveOrUpdateOftenUsedPharmacy(pharmacy) + } + + suspend fun saveOrUpdateFavoritePharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + repository.saveOrUpdateFavoritePharmacy(pharmacy) + } + + suspend fun deleteFavoritePharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + withContext(dispatchers.IO) { + repository.deleteFavoritePharmacy(pharmacy) + } + } + + suspend fun deleteOverviewPharmacy(overviewPharmacy: OverviewPharmacyData.OverviewPharmacy) { + repository.deleteOverviewPharmacy(overviewPharmacy) + } + + suspend fun searchPharmacyByTelematikId( + telematikId: String + ): Result> = withContext(dispatchers.IO) { + repository.searchPharmacyByTelematikId(telematikId) + .map { it.pharmacies.mapToUseCasePharmacies() } + } + + suspend fun isPharmacyInFavorites(telematikId: String): Boolean = withContext(dispatchers.IO) { + repository.isPharmacyInFavorites(telematikId) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacySearchUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacySearchUseCase.kt index 8c2c6db6..7e4d403d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacySearchUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacySearchUseCase.kt @@ -23,50 +23,39 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import androidx.paging.PagingState -import com.squareup.moshi.Moshi import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.api.Result +import de.gematik.ti.erp.app.fhir.model.CommunicationPayload +import de.gematik.ti.erp.app.fhir.model.LocalPharmacyService +import de.gematik.ti.erp.app.fhir.model.Pharmacy +import de.gematik.ti.erp.app.fhir.model.createCommunicationDispenseRequest +import de.gematik.ti.erp.app.pharmacy.model.PharmacyData +import de.gematik.ti.erp.app.pharmacy.model.shippingContact import de.gematik.ti.erp.app.pharmacy.repository.PharmacyRepository -import de.gematik.ti.erp.app.pharmacy.repository.model.CommunicationPayload -import de.gematik.ti.erp.app.pharmacy.repository.model.Pharmacy +import de.gematik.ti.erp.app.pharmacy.repository.ShippingContactRepository import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.detail.ui.model.mapToUIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.repository.Mapper -import de.gematik.ti.erp.app.prescription.repository.PROFILE import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption -import de.gematik.ti.erp.app.prescription.repository.extractMedication -import de.gematik.ti.erp.app.prescription.repository.extractMedicationRequest -import de.gematik.ti.erp.app.prescription.repository.extractPatient -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase +import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.withContext -import okhttp3.ResponseBody -import org.hl7.fhir.r4.model.Communication -import org.hl7.fhir.r4.model.Identifier -import org.hl7.fhir.r4.model.Meta -import org.hl7.fhir.r4.model.Reference -import org.hl7.fhir.r4.model.StringType -import javax.inject.Inject -import kotlin.math.max -import kotlinx.coroutines.flow.first +import java.util.UUID -private const val initialResultsPerPage = 80 +// can't be modified; the backend will always return 80 entries on the first page +const val PharmacyInitialResultsPerPage = 80 +const val PharmacyNextResultsPerPage = 10 -class PharmacySearchUseCase @Inject constructor( +class PharmacySearchUseCase( private val repository: PharmacyRepository, + private val shippingContactRepository: ShippingContactRepository, private val prescriptionRepository: PrescriptionRepository, private val settingsUseCase: SettingsUseCase, - private val mapper: Mapper, - private val moshi: Moshi, - private val dispatchProvider: DispatchProvider, - private val profilesUseCase: ProfilesUseCase + private val dispatchers: DispatchProvider ) { data class PharmacyPagingKey(val bundleId: String, val offset: Int) @@ -97,189 +86,213 @@ class PharmacySearchUseCase @Inject constructor( when (params) { is LoadParams.Refresh -> { - return when (val resultSearchBundle = repository.searchPharmacies(name, filter)) { - is Result.Error -> LoadResult.Error( - resultSearchBundle.exception - ) - is Result.Success -> { + return repository.searchPharmacies(name, filter) + .map { LoadResult.Page( - data = mapPharmacies(resultSearchBundle.data.pharmacies), - nextKey = if (resultSearchBundle.data.bundleResultCount == initialResultsPerPage) { + data = it.pharmacies.mapToUseCasePharmacies(), + nextKey = if (it.bundleResultCount == PharmacyInitialResultsPerPage) { PharmacyPagingKey( - resultSearchBundle.data.bundleId, - resultSearchBundle.data.bundleResultCount + it.bundleId, + it.bundleResultCount ) } else { null }, prevKey = null ) - } - } + }.getOrElse { LoadResult.Error(it) } } is LoadParams.Append, is LoadParams.Prepend -> { val key = params.key!! - val resultSearchBundle = - repository.searchPharmaciesByBundle(key.bundleId, offset = key.offset, count = count) - - return when (resultSearchBundle) { - is Result.Error -> LoadResult.Error( - resultSearchBundle.exception - ) - is Result.Success -> { - val nextKey = if (resultSearchBundle.data.bundleResultCount == count) { - PharmacyPagingKey( - key.bundleId, - key.offset + resultSearchBundle.data.bundleResultCount - ) - } else { - null - } - val prevKey = if (key.offset == 0) null else key.copy(offset = max(0, key.offset - count)) - - LoadResult.Page( - data = mapPharmacies(resultSearchBundle.data.pharmacies), - nextKey = nextKey, - prevKey = prevKey, - itemsBefore = if (prevKey != null) count else 0, - itemsAfter = if (nextKey != null) count else 0, + return repository.searchPharmaciesByBundle(key.bundleId, offset = key.offset, count = count).map { + val nextKey = if (it.bundleResultCount == count) { + PharmacyPagingKey( + key.bundleId, + key.offset + it.bundleResultCount ) + } else { + null } - } + val prevKey = if (key.offset == 0) null else key.copy(offset = max(0, key.offset - count)) + + LoadResult.Page( + data = it.pharmacies.mapToUseCasePharmacies(), + nextKey = nextKey, + prevKey = prevKey, + itemsBefore = if (prevKey != null) count else 0, + itemsAfter = if (nextKey != null) count else 0 + ) + }.getOrElse { LoadResult.Error(it) } } } } } - private val comAdapter by lazy { - moshi.adapter(CommunicationPayload::class.java) - } - - val previousSearch: Flow = - settingsUseCase.pharmacySearch.map { pharmacySearchModel -> - pharmacySearchModel.let { - PharmacyUseCaseData.SearchData( - name = it.name, - filter = PharmacyUseCaseData.Filter( - ready = it.filterReady, - openNow = it.filterOpenNow, - deliveryService = it.filterDeliveryService, - onlineService = it.filterOnlineService, - ), - locationMode = if (it.locationEnabled) PharmacyUseCaseData.LocationMode.EnabledWithoutPosition else PharmacyUseCaseData.LocationMode.Disabled - ) - } - }.flowOn(dispatchProvider.io()) - suspend fun searchPharmacies( searchData: PharmacyUseCaseData.SearchData ): Flow> { settingsUseCase.savePharmacySearch( - name = searchData.name, - locationEnabled = searchData.locationMode !is PharmacyUseCaseData.LocationMode.Disabled, - filterReady = searchData.filter.ready, - filterDeliveryService = searchData.filter.deliveryService, - filterOnlineService = searchData.filter.onlineService, - filterOpenNow = searchData.filter.openNow + SettingsData.PharmacySearch( + name = searchData.name, + locationEnabled = searchData.locationMode !is PharmacyUseCaseData.LocationMode.Disabled, + ready = searchData.filter.ready, + deliveryService = searchData.filter.deliveryService, + onlineService = searchData.filter.onlineService, + openNow = searchData.filter.openNow + ) ) return Pager( PagingConfig( - pageSize = 10, - initialLoadSize = initialResultsPerPage, - maxSize = initialResultsPerPage * 2 + pageSize = PharmacyNextResultsPerPage, + initialLoadSize = PharmacyInitialResultsPerPage, + maxSize = PharmacyInitialResultsPerPage * 2 ), pagingSourceFactory = { PharmacyPagingSource(searchData) } - ).flow + ).flow.flowOn(dispatchers.IO) } - private suspend fun mapPharmacies(pharmacies: List): List = - withContext(dispatchProvider.unconfined()) { - pharmacies.map { pharmacy -> - PharmacyUseCaseData.Pharmacy( - name = pharmacy.name, - address = pharmacy.address.let { - "${it.lines.joinToString()}\n${it.postalCode} ${it.city}" - }, - location = pharmacy.location, - distance = null, - contacts = pharmacy.contacts, - provides = pharmacy.provides, - openingHours = pharmacy.provides.first().openingHours, - telematikId = pharmacy.telematikId, - roleCode = pharmacy.roleCode, - ready = pharmacy.ready - ) + fun hasRedeemableTasks( + profileId: ProfileIdentifier + ): Flow = + combine( + prescriptionRepository.syncedTasks(profileId).map { tasks -> + tasks.filter { + it.redeemState().isRedeemable() + } + }, + prescriptionRepository.scannedTasks(profileId).map { tasks -> + tasks.filter { + it.isRedeemable() + } } + ) { syncedTasks, scannedTasks -> + syncedTasks.isNotEmpty() || scannedTasks.isNotEmpty() } fun prescriptionDetailsForOrdering( - vararg taskIds: String - ): Flow> { - return prescriptionRepository.loadTasksForTaskId(*taskIds).take(1).map { taskList -> - taskList.filter { - it.accessCode != null - }.map { task -> - val bundle = mapper.parseKBVBundle(requireNotNull(task.rawKBVBundle)) - mapToUIPrescriptionOrder( - task, - requireNotNull(bundle.extractMedication()), - requireNotNull(bundle.extractMedicationRequest()), - requireNotNull(bundle.extractPatient()), + profileId: ProfileIdentifier + ): Flow = + combine( + shippingContactRepository.shippingContact(), + prescriptionRepository.syncedTasks(profileId).map { tasks -> + tasks.filter { + it.redeemState().isRedeemable() + } + }, + prescriptionRepository.scannedTasks(profileId).map { tasks -> + tasks.filter { + it.isRedeemable() + } + } + + ) { shippingContacts, syncedTasks, scannedTasks -> + + val shippingContact = if (syncedTasks.isNotEmpty()) { + shippingContacts ?: run { + syncedTasks.first().shippingContact().also { + shippingContactRepository.saveShippingContact(it) + } + } + } else { + shippingContacts + } + + val tasks = scannedTasks.map { task -> + PharmacyUseCaseData.PrescriptionOrder( + taskId = task.taskId, + accessCode = task.accessCode, + title = "", + scannedOn = task.scannedOn, + substitutionsAllowed = false + ) + } + syncedTasks.map { task -> + PharmacyUseCaseData.PrescriptionOrder( + taskId = task.taskId, + accessCode = task.accessCode!!, + title = task.medicationRequestMedicationName(), + substitutionsAllowed = false ) } + + PharmacyUseCaseData.OrderState( + tasks, + PharmacyUseCaseData.ShippingContact( + name = shippingContact?.name ?: "", + line1 = shippingContact?.line1 ?: "", + line2 = shippingContact?.line2 ?: "", + postalCodeAndCity = shippingContact?.postalCodeAndCity ?: "", + telephoneNumber = shippingContact?.telephoneNumber ?: "", + mail = shippingContact?.mail ?: "", + deliveryInformation = shippingContact?.deliveryInformation ?: "" + ) + ) }.flowOn(Dispatchers.Default) + + suspend fun saveShippingContact(contact: PharmacyUseCaseData.ShippingContact) { + shippingContactRepository.saveShippingContact( + mapShippingContact(contact) + ) } suspend fun redeemPrescription( + profileId: ProfileIdentifier, redeemOption: RemoteRedeemOption, - order: UIPrescriptionOrder, + orderId: UUID, + order: PharmacyUseCaseData.PrescriptionOrder, + contact: PharmacyUseCaseData.ShippingContact, pharmacyTelematikId: String - ): Result { - val profileName = profilesUseCase.activeProfileName().first() - val payload = generatePayLoad(redeemOption, order.patientName, order.address) - val reference = assembleReference(order.taskId, order.accessCode) - val communication = generateFhirObject(reference, pharmacyTelematikId, payload) - return prescriptionRepository.redeemPrescription(profileName, communication) - } - - private fun generatePayLoad( - redeemOption: RemoteRedeemOption, - patientName: String, - address: String - ): String { - val com = CommunicationPayload( - version = "1", - supplyOptionsType = redeemOption.type, - name = patientName, - address = address.split(",").toTypedArray(), - phone = null - ) - return comAdapter.toJson(com) - } + ): Result { + val accessCode = if (order.scannedOn != null) { + order.accessCode + } else { + null + } - private fun generateFhirObject(reference: String, telematicsId: String, payload: String) = - Communication().apply { - meta = Meta().addProfile(PROFILE) - addBasedOn(Reference(reference)) - addPayload( - Communication.CommunicationPayloadComponent().apply { - content = StringType(payload) - } + val comDisp = createCommunicationDispenseRequest( + orderId = orderId.toString(), + taskId = order.taskId, + accessCode = order.accessCode, + recipientTID = pharmacyTelematikId, + payload = CommunicationPayload( + version = "1", + supplyOptionsType = redeemOption.type, + name = contact.name, + address = listOf(contact.line1, contact.line2, contact.postalCodeAndCity), + phone = contact.telephoneNumber, + hint = contact.deliveryInformation ) - status = Communication.CommunicationStatus.UNKNOWN - addRecipient(Reference().setIdentifier(createIdentifier(telematicsId))) - } + ) - private fun createIdentifier(pharmacyTelematikId: String): Identifier { - val identifier = Identifier() - identifier.system = "https://gematik.de/fhir/NamingSystem/TelematikID" - identifier.value = pharmacyTelematikId - return identifier + return prescriptionRepository.redeemPrescription(profileId, comDisp, accessCode = accessCode) } - private fun assembleReference(taskId: String, accessCode: String): String { - return "Task/$taskId\$accept?ac=$accessCode" - } + private fun mapShippingContact(contact: PharmacyUseCaseData.ShippingContact) = + PharmacyData.ShippingContact( + name = contact.name.trim(), + line1 = contact.line1.trim(), + line2 = contact.line2.trim(), + postalCodeAndCity = contact.postalCodeAndCity.trim(), + telephoneNumber = contact.telephoneNumber.trim(), + mail = contact.mail.trim(), + deliveryInformation = contact.deliveryInformation.trim() + ) } + +fun List.mapToUseCasePharmacies(): List = + map { pharmacy -> + PharmacyUseCaseData.Pharmacy( + name = pharmacy.name, + address = pharmacy.address.let { + "${it.lines.joinToString()}\n${it.postalCode} ${it.city}" + }, + location = pharmacy.location, + distance = null, + contacts = pharmacy.contacts, + provides = pharmacy.provides, + openingHours = (pharmacy.provides.find { it is LocalPharmacyService } as LocalPharmacyService).openingHours, + telematikId = pharmacy.telematikId, + ready = pharmacy.ready + ) + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacyUseCaseData.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacyUseCaseData.kt index c5699e53..6d3ff078 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacyUseCaseData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacyUseCaseData.kt @@ -20,30 +20,33 @@ package de.gematik.ti.erp.app.pharmacy.usecase.model import android.os.Parcelable import androidx.compose.runtime.Immutable -import de.gematik.ti.erp.app.pharmacy.repository.model.Location -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningHours -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyContacts -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.RoleCode +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.fhir.model.OpeningHours +import de.gematik.ti.erp.app.fhir.model.PharmacyContacts +import de.gematik.ti.erp.app.fhir.model.Location +import de.gematik.ti.erp.app.fhir.model.PharmacyService import kotlinx.parcelize.Parcelize +import java.time.Instant + +private const val DefaultRadiusInMeter = 999 * 1000.0 object PharmacyUseCaseData { @Parcelize @Immutable data class Filter( + val nearBy: Boolean = false, val ready: Boolean = false, val deliveryService: Boolean = false, val onlineService: Boolean = false, - val openNow: Boolean = false, + val openNow: Boolean = false ) : Parcelable { fun isAnySet(): Boolean = - ready || deliveryService || onlineService || openNow + nearBy || ready || deliveryService || onlineService || openNow } /** * Represents a pharmacy. */ - @Parcelize @Immutable data class Pharmacy( val name: String, @@ -54,9 +57,10 @@ object PharmacyUseCaseData { val provides: List, val openingHours: OpeningHours?, val telematikId: String, - val roleCode: List, val ready: Boolean - ) : Parcelable { + ) { + + @Stable fun removeLineBreaksFromAddress(): String { if (address.isNullOrEmpty()) return "" return address.replace("\n", ", ") @@ -67,15 +71,14 @@ object PharmacyUseCaseData { /** * We only store the information if gps was enabled and not the actual position. */ - @Parcelize @Immutable - object EnabledWithoutPosition : LocationMode(), Parcelable - @Parcelize + object EnabledWithoutPosition : LocationMode() + @Immutable - object Disabled : LocationMode(), Parcelable - @Parcelize + object Disabled : LocationMode() + @Immutable - class Enabled(val location: Location) : LocationMode(), Parcelable + data class Enabled(val location: Location, val radiusInMeter: Double = DefaultRadiusInMeter) : LocationMode() } @Immutable @@ -84,8 +87,65 @@ object PharmacyUseCaseData { /** * State with list of pharmacies */ + @Immutable data class State( - val search: SearchData, - val showLocationHint: Boolean + val search: SearchData + ) + + @Immutable + data class PrescriptionOrder( + val taskId: String, + val accessCode: String, + val title: String?, + val scannedOn: Instant? = null, + val substitutionsAllowed: Boolean + ) + + @Immutable + data class ShippingContact( + val name: String, + val line1: String, + val line2: String, + val postalCodeAndCity: String, + val telephoneNumber: String, + val mail: String, + val deliveryInformation: String + ) { + @Stable + fun toList() = listOf( + name, + line1, + line2, + postalCodeAndCity, + telephoneNumber, + mail, + deliveryInformation + ).filter { it.isNotBlank() } + + @Stable + fun address() = listOf( + line1, + line2, + postalCodeAndCity + ).filter { it.isNotBlank() } + + @Stable + fun other() = listOf( + telephoneNumber, + mail, + deliveryInformation + ).filter { it.isNotBlank() } + + @Stable + fun phoneOrAddressMissing() = telephoneNumber.isBlank() || addressIsMissing() + + @Stable + fun addressIsMissing() = name.isBlank() || line1.isBlank() || postalCodeAndCity.isBlank() + } + + @Immutable + data class OrderState( + val prescriptions: List, + val contact: ShippingContact ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/PrescriptionModule.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/PrescriptionModule.kt new file mode 100644 index 00000000..0686b211 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/PrescriptionModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription + +import de.gematik.ti.erp.app.prescription.repository.LocalDataSource +import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository +import de.gematik.ti.erp.app.prescription.repository.RemoteDataSource +import de.gematik.ti.erp.app.prescription.ui.TwoDCodeProcessor +import de.gematik.ti.erp.app.prescription.ui.TwoDCodeScanner +import de.gematik.ti.erp.app.prescription.ui.TwoDCodeValidator +import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase +import de.gematik.ti.erp.app.prescription.usecase.RefreshPrescriptionUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +val prescriptionModule = DI.Module("prescriptionModule") { + bindProvider { TwoDCodeProcessor() } + bindProvider { TwoDCodeScanner(instance()) } + bindProvider { TwoDCodeValidator() } + bindSingleton { LocalDataSource(instance()) } + bindSingleton { PrescriptionRepository(instance(), instance(), instance()) } + bindSingleton { RemoteDataSource(instance()) } + bindSingleton { PrescriptionUseCase(instance(), instance()) } + bindSingleton { RefreshPrescriptionUseCase(instance(), instance(), instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/AccidentInformation.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/AccidentInformation.kt new file mode 100644 index 00000000..1134efd4 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/AccidentInformation.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +private const val NoInfo = "-" + +@Composable +fun AccidentInformation( + prescription: PrescriptionData.Synced, + onBack: () -> Unit +) { + val isAccident by derivedStateOf { prescription.medicationRequest.dateOfAccident != null } + + val listState = rememberLazyListState() + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.pres_detail_accident_header), + listState = listState, + onBack = onBack, + navigationMode = NavigationBarMode.Back + ) { innerPadding -> + LazyColumn( + Modifier.padding(innerPadding), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + Label( + text = if (isAccident) { + stringResource(R.string.pres_detail_yes) + } else { + stringResource(R.string.pres_detail_no) + }, + label = stringResource(R.string.pres_detail_accident_header) + ) + } + item { + val text = if (isAccident) { + remember(LocalConfiguration.current, prescription.medicationRequest.dateOfAccident) { + val dtFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + prescription.medicationRequest.dateOfAccident?.let { + LocalDateTime + .ofInstant(it, ZoneOffset.UTC) + .toLocalDate() + .format(dtFormatter) + } ?: MissingValue + } + } else { + NoInfo + } + Label( + text = text, + label = stringResource(R.string.pres_detail_accident_label_date) + ) + } + item { + val text = if (isAccident) { + prescription.medicationRequest.location ?: MissingValue + } else { + NoInfo + } + Label( + text = text, + label = stringResource(R.string.pres_detail_accident_label_location) + ) + SpacerMedium() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt new file mode 100644 index 00000000..7c1a811a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeletePrescriptions.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.cardwall.mini.ui.Authenticator +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceState +import de.gematik.ti.erp.app.prescription.ui.catchAndTransformRemoteExceptions +import de.gematik.ti.erp.app.prescription.ui.retryWithAuthenticator +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import java.net.HttpURLConnection + +interface DeletePrescriptionsBridge { + suspend fun deletePrescription(profileId: ProfileIdentifier, taskId: String): Result +} + +@Stable +class DeletePrescriptions( + private val bridge: DeletePrescriptionsBridge, + private val authenticator: Authenticator +) { + sealed interface State : PrescriptionServiceState { + object Deleted : State + + sealed interface Error : State, PrescriptionServiceErrorState { + object PrescriptionWorkflowBlocked : Error + object PrescriptionNotFound : Error + } + } + + suspend fun deletePrescription( + profileId: ProfileIdentifier, + taskId: String + ): PrescriptionServiceState = + deletePrescriptionFlow(profileId = profileId, taskId = taskId).cancellable().first() + + private fun deletePrescriptionFlow(profileId: ProfileIdentifier, taskId: String) = + flow { + emit(bridge.deletePrescription(profileId = profileId, taskId = taskId)) + }.map { result -> + result.fold( + onSuccess = { + State.Deleted + }, + onFailure = { + if (it is ApiCallException) { + when (it.response.code()) { + HttpURLConnection.HTTP_FORBIDDEN -> State.Error.PrescriptionWorkflowBlocked + HttpURLConnection.HTTP_GONE -> State.Error.PrescriptionNotFound + else -> throw it + } + } else { + throw it + } + } + ) + } + .retryWithAuthenticator( + isUserAction = true, + authenticate = authenticator.authenticateForPrescriptions(profileId) + ) + .catchAndTransformRemoteExceptions() + .flowOn(Dispatchers.IO) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeleteSnackbar.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeleteSnackbar.kt new file mode 100644 index 00000000..7e5e3a44 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeleteSnackbar.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import android.content.Context +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.ui.GenerellErrorState +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState + +fun deleteErrorMessage(context: Context, deleteState: PrescriptionServiceErrorState): String? = + when (deleteState) { + GenerellErrorState.NetworkNotAvailable -> + context.getString(R.string.error_message_network_not_available) + is GenerellErrorState.ServerCommunicationFailedWhileRefreshing -> + context.getString(R.string.error_message_server_communication_failed).format(deleteState.code) + GenerellErrorState.FatalTruststoreState -> + context.getString(R.string.error_message_vau_error) + is DeletePrescriptions.State.Error.PrescriptionWorkflowBlocked -> + context.getString(R.string.logout_delete_in_progress) + is DeletePrescriptions.State.Error.PrescriptionNotFound -> + context.getString(R.string.prescription_not_found) + is GenerellErrorState.NoneEnrolled -> + context.getString(R.string.no_auth_enrolled) + else -> null + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DetailNavController.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DetailNavController.kt new file mode 100644 index 00000000..d62a599e --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DetailNavController.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.DialogNavigator + +@Composable +fun rememberNotSaveableNavController(): NavHostController { + val context = LocalContext.current + return remember { + createNavController(context) + } +} + +private fun createNavController(context: Context) = + NavHostController(context).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + navigatorProvider.addNavigator(DialogNavigator()) + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/InfoSheet.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/InfoSheet.kt new file mode 100644 index 00000000..230d187a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/InfoSheet.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowForward +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.dateTimeMediumText +import java.time.Instant +import java.time.temporal.ChronoUnit + +sealed class PrescriptionDetailBottomSheetContent { + @Stable + class HowLongValid(val prescription: PrescriptionData.Synced) : PrescriptionDetailBottomSheetContent() + + object SubstitutionAllowed : PrescriptionDetailBottomSheetContent() + object DirectAssignment : PrescriptionDetailBottomSheetContent() + object EmergencyFee : PrescriptionDetailBottomSheetContent() + object AdditionalFeeNotExempt : PrescriptionDetailBottomSheetContent() + object AdditionalFeeExempt : PrescriptionDetailBottomSheetContent() + object Scanned : PrescriptionDetailBottomSheetContent() + object Failure : PrescriptionDetailBottomSheetContent() +} + +@Composable +fun PrescriptionDetailInfoSheetContent( + infoContent: PrescriptionDetailBottomSheetContent +) { + when (infoContent) { + PrescriptionDetailBottomSheetContent.DirectAssignment -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_da_title), + info = stringResource(R.string.pres_details_exp_da_info) + ) + + PrescriptionDetailBottomSheetContent.EmergencyFee -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_no_em_fee_title), + info = stringResource(R.string.pres_details_exp_no_em_fee_info) + ) + + PrescriptionDetailBottomSheetContent.AdditionalFeeNotExempt -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_add_fee_title), + info = stringResource(R.string.pres_details_exp_add_fee_info) + ) + + PrescriptionDetailBottomSheetContent.AdditionalFeeExempt -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_no_add_fee_title), + info = stringResource(R.string.pres_details_exp_no_add_fee_info) + ) + + is PrescriptionDetailBottomSheetContent.HowLongValid -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_valid_title) + ) { + val start = if (infoContent.prescription.medicationRequest.multiplePrescriptionInfo.indicator) { + infoContent.prescription.medicationRequest.multiplePrescriptionInfo.start + ?: infoContent.prescription.authoredOn + } else { + infoContent.prescription.authoredOn + } + Column { + DateRange(start = start, end = infoContent.prescription.acceptUntil ?: start) + SpacerSmall() + Text( + stringResource(R.string.pres_details_exp_valid_info_accept), + style = AppTheme.typography.body2l + ) + if (!infoContent.prescription.medicationRequest.multiplePrescriptionInfo.indicator) { + SpacerMedium() + DateRange( + start = remember { + infoContent.prescription.acceptUntil?.plus( + 1, + ChronoUnit.DAYS + ) ?: start + }, + end = infoContent.prescription.expiresOn ?: start + ) + SpacerSmall() + Text( + stringResource(R.string.pres_details_exp_valid_info_expires), + style = AppTheme.typography.body2l + ) + } + } + } + + PrescriptionDetailBottomSheetContent.SubstitutionAllowed -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_sub_allowed_title), + info = stringResource(R.string.pres_details_exp_sub_allowed_info) + ) + + is PrescriptionDetailBottomSheetContent.Scanned -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_scanned_title), + info = stringResource(R.string.pres_details_exp_scanned_info) + ) + + PrescriptionDetailBottomSheetContent.Failure -> + PrescriptionDetailInfoSheetContent( + title = stringResource(R.string.pres_details_exp_failure_title), + info = stringResource(R.string.pres_details_exp_failure_info) + ) + } +} + +@Composable +private fun DateRange(start: Instant, end: Instant) { + val startText = remember { dateTimeMediumText(start) } + val endText = remember { dateTimeMediumText(end) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + Text(startText, style = AppTheme.typography.subtitle2l) + Icon(Icons.Rounded.ArrowForward, null, tint = AppTheme.colors.primary600, modifier = Modifier.size(16.dp)) + Text(endText, style = AppTheme.typography.subtitle2l) + } +} + +@Composable +private fun PrescriptionDetailInfoSheetContent( + title: String, + info: String +) { + PrescriptionDetailInfoSheetContent( + title = title + ) { + Text(info, style = AppTheme.typography.body2l) + } +} + +@Composable +private fun PrescriptionDetailInfoSheetContent( + title: String, + content: @Composable () -> Unit +) { + Column( + Modifier + .padding(horizontal = PaddingDefaults.Medium) + .padding(top = PaddingDefaults.Small, bottom = PaddingDefaults.XXLarge) + ) { + Icon( + Icons.Rounded.DragHandle, + null, + tint = AppTheme.colors.neutral600, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + SpacerMedium() + Text(title, style = AppTheme.typography.subtitle1) + SpacerMedium() + Box(Modifier.verticalScroll(rememberScrollState())) { + content() + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/IngredientScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/IngredientScreen.kt new file mode 100644 index 00000000..a874d2bb --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/IngredientScreen.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.SnackbarHost +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium + +@Composable +fun IngredientScreen(ingredient: SyncedTaskData.Ingredient, onBack: () -> Unit) { + val scaffoldState = rememberScaffoldState() + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + scaffoldState = scaffoldState, + listState = listState, + onBack = onBack, + topBarTitle = stringResource(R.string.synced_medication_ingredient_header), + navigationMode = NavigationBarMode.Back, + snackbarHost = { SnackbarHost(it, modifier = Modifier.navigationBarsPadding()) }, + actions = {} + ) { innerPadding -> + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize().padding(innerPadding), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + IngredientNameLabel(ingredient.text) + } + + ingredient.amount?.let { + item { + IngredientAmountLabel(it) + } + } + ingredient.number?.let { + item { + IngredientNumberLabel(it) + } + } + + ingredient.form?.let { + item { + FormLabel(it) + } + } + + ingredient.strength?.let { + item { + StrengtLabel(it) + } + } + item { + SpacerMedium() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/MedicationOverviewScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/MedicationOverviewScreen.kt new file mode 100644 index 00000000..7ae5fe38 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/MedicationOverviewScreen.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.SnackbarHost +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge + +@Composable +fun MedicationOverviewScreen( + prescription: PrescriptionData.Synced, + onClickMedication: (PrescriptionData.Medication) -> Unit, + onBack: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + scaffoldState = scaffoldState, + listState = listState, + onBack = onBack, + topBarTitle = stringResource(R.string.synced_medication_detail_header), + navigationMode = NavigationBarMode.Back, + snackbarHost = { SnackbarHost(it, modifier = Modifier.navigationBarsPadding()) }, + actions = {} + ) { innerPadding -> + prescription.medicationRequest.medication?.let { med -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + Text( + stringResource(R.string.medication_overview_prescribed_header), + style = AppTheme.typography.h6, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) + SpacerMedium() + Label( + text = med.text, + label = null, + onClick = { + onClickMedication(PrescriptionData.Medication.Request(prescription.medicationRequest)) + } + ) + } + item { + SpacerXXLarge() + Text( + stringResource(R.string.medication_overview_dispenses_header), + style = AppTheme.typography.h6, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) + SpacerMedium() + } + + prescription.medicationDispenses.forEach { dispense -> + dispense.medication?.let { + item { + Label( + text = it.text, + label = null, + onClick = { + onClickMedication(PrescriptionData.Medication.Dispense(dispense)) + } + ) + } + } + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/OrganizationScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/OrganizationScreen.kt new file mode 100644 index 00000000..f1862d11 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/OrganizationScreen.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium + +@Composable +fun OrganizationScreen( + prescription: PrescriptionData.Synced, + onBack: () -> Unit +) { + val organization = prescription.organization + val noValueText = stringResource(R.string.pres_details_no_value) + val listState = rememberLazyListState() + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Organization.Screen), + topBarTitle = stringResource(R.string.pres_detail_organization_header), + listState = listState, + onBack = onBack, + navigationMode = NavigationBarMode.Back + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .testTag(TestTag.Prescriptions.Details.Organization.Content), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Organization.Name), + text = organization.name ?: noValueText, + label = stringResource(id = R.string.pres_detail_organization_label_name) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Organization.Address), + text = organization.address?.joinToString()?.takeIf { it.isNotEmpty() } ?: noValueText, + label = stringResource(id = R.string.pres_detail_organization_label_address) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Organization.BSNR), + text = organization.uniqueIdentifier ?: noValueText, + label = stringResource(id = R.string.pres_detail_organization_label_id) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Organization.Phone), + text = organization.phone ?: noValueText, + label = stringResource(id = R.string.pres_detail_organization_label_telephone) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Organization.EMail), + text = organization.mail ?: noValueText, + label = stringResource(id = R.string.pres_detail_organization_label_email) + ) + SpacerMedium() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PatientScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PatientScreen.kt new file mode 100644 index 00000000..444ef37a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PatientScreen.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.insuranceState +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.prescription.repository.statusMapping +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun PatientScreen( + prescription: PrescriptionData.Synced, + onBack: () -> Unit +) { + val patient = prescription.patient + val insurance = prescription.insurance + val noValueText = stringResource(R.string.pres_details_no_value) + val listState = rememberLazyListState() + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Patient.Screen), + topBarTitle = stringResource(R.string.pres_detail_patient_header), + listState = listState, + onBack = onBack, + navigationMode = NavigationBarMode.Back + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .testTag(TestTag.Prescriptions.Details.Patient.Content), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Patient.Name), + text = patient.name ?: noValueText, + label = stringResource(R.string.pres_detail_patient_label_name) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Patient.Address), + text = patient.address?.joinToString()?.takeIf { it.isNotEmpty() } ?: noValueText, + label = stringResource(R.string.pres_detail_patient_label_address) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Patient.BirthDate), + text = remember(LocalConfiguration.current, patient) { + val dtFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + + patient.birthdate?.let { + LocalDateTime + .ofInstant(it, ZoneOffset.UTC) + .format(dtFormatter) + } ?: noValueText + }, + label = stringResource(R.string.pres_detail_patient_label_birthdate) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Patient.InsuranceName), + text = insurance.name ?: noValueText, + label = stringResource(R.string.pres_detail_patient_label_insurance) + ) + } + item { + Label( + modifier = Modifier + .testTag(TestTag.Prescriptions.Details.Patient.InsuranceState) + .semantics { + insuranceState = insurance.status + }, + text = insurance.status?.let { statusMapping[it]?.let { stringResource(it) } } ?: noValueText, + label = stringResource(R.string.pres_detail_patient_label_member_status) + ) + } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Patient.KVNR), + text = patient.insuranceIdentifier ?: noValueText, + label = stringResource(R.string.pres_detail_patient_label_insurance_id) + ) + SpacerMedium() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriberScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriberScreen.kt new file mode 100644 index 00000000..319be743 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriberScreen.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium + +@Composable +fun PrescriberScreen( + prescription: PrescriptionData.Synced, + onBack: () -> Unit +) { + val practitioner = prescription.practitioner + val noValueText = stringResource(R.string.pres_details_no_value) + val listState = rememberLazyListState() + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.pres_detail_practitioner_header), + listState = listState, + onBack = onBack, + navigationMode = NavigationBarMode.Back + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + Label( + text = practitioner.name ?: noValueText, + label = stringResource(R.string.pres_detail_practitioner_label_name) + ) + } + item { + Label( + text = practitioner.qualification ?: noValueText, + label = stringResource(R.string.pres_detail_practitioner_label_qualification) + ) + } + item { + Label( + text = practitioner.practitionerIdentifier ?: noValueText, + label = stringResource(R.string.pres_detail_practitioner_label_id) + ) + SpacerMedium() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt index d8493088..2ce202b9 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailScreen.kt @@ -16,491 +16,391 @@ * */ +@file:OptIn(ExperimentalMaterialApi::class) + package de.gematik.ti.erp.app.prescription.detail.ui import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface +import androidx.compose.material.IconButton +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.SnackbarHost import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.api.ApiCallException -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.TaskStatus -import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens -import de.gematik.ti.erp.app.mainscreen.ui.TaskIds +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionDetailsNavigationScreens -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetailScanned -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetailSynced -import de.gematik.ti.erp.app.prescription.repository.InsuranceCompanyDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationRequestDetail -import de.gematik.ti.erp.app.prescription.repository.OrganizationDetail -import de.gematik.ti.erp.app.prescription.repository.PatientDetail -import de.gematik.ti.erp.app.prescription.repository.PractitionerDetail -import de.gematik.ti.erp.app.prescription.repository.codeToDosageFormMapping -import de.gematik.ti.erp.app.prescription.ui.expiryOrAcceptString -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import de.gematik.ti.erp.app.redeem.ui.DataMatrixCode +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.prescription.ui.DirectAssignmentChip +import de.gematik.ti.erp.app.prescription.ui.FailureDetailsStatusChip +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.ScannedChip +import de.gematik.ti.erp.app.prescription.ui.SubstitutionAllowedChip +import de.gematik.ti.erp.app.prescription.ui.prescriptionStateInfo import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.AlertDialog +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog -import de.gematik.ti.erp.app.utils.compose.HintCard -import de.gematik.ti.erp.app.utils.compose.HintCardDefaults -import de.gematik.ti.erp.app.utils.compose.HintSmallImage -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton -import de.gematik.ti.erp.app.utils.compose.HintTextLearnMoreButton -import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer4 -import de.gematik.ti.erp.app.utils.compose.Spacer8 +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonSmall +import de.gematik.ti.erp.app.utils.compose.PrimaryButtonTiny +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerShortMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.SpacerXLarge +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge import de.gematik.ti.erp.app.utils.compose.annotatedLinkStringLight -import de.gematik.ti.erp.app.utils.compose.createToastShort -import de.gematik.ti.erp.app.utils.compose.navigationModeState -import de.gematik.ti.erp.app.utils.compose.phrasedDateString -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale - -private const val MISSING_VALUE = "---" -private const val FORBIDDEN = 403 +import de.gematik.ti.erp.app.utils.compose.dateWithIntroductionString +import de.gematik.ti.erp.app.utils.compose.handleIntent +import de.gematik.ti.erp.app.utils.compose.provideEmailIntent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel + +const val MissingValue = "---" @Composable fun PrescriptionDetailsScreen( taskId: String, - mainNavController: NavController, - viewModel: PrescriptionDetailsViewModel = hiltViewModel() + mainNavController: NavController ) { - val navController = rememberNavController() + val viewModel: PrescriptionDetailsViewModel by rememberViewModel() - val navigationMode by navController.navigationModeState(PrescriptionDetailsNavigationScreens.PrescriptionDetails.route) - NavHost( - navController, - startDestination = PrescriptionDetailsNavigationScreens.PrescriptionDetails.route - ) { - composable(PrescriptionDetailsNavigationScreens.PrescriptionDetails.route) { - PrescriptionDetailsWithScaffold( - mainNavController, - viewModel, - taskId, - navigationMode, - onCancel = { mainNavController.popBackStack() } - ) + val prescription by produceState(null) { + viewModel.screenState(taskId).collect { + value = it } } -} -@OptIn(ExperimentalAnimationApi::class) -@Composable -private fun PrescriptionDetailsWithScaffold( - mainNavController: NavController, - viewModel: PrescriptionDetailsViewModel, - taskId: String, - navigationMode: NavigationMode, - onCancel: () -> Unit -) { - val state by produceState(null) { - value = viewModel.detailedPrescription(taskId) - } + var selectedMedication: PrescriptionData.Medication? by remember { mutableStateOf(null) } + var selectedIngredient: SyncedTaskData.Ingredient? by remember { mutableStateOf(null) } - val lowDetailRedeemEvents by produceState>(mutableListOf()) { - viewModel.loadLowDetailEvents(taskId).collect { - value = it + val mainScope = rememberCoroutineScope { Dispatchers.Main } + val onBack: () -> Unit = { + mainScope.launch { + mainNavController.popBackStack() } } - val header = stringResource(id = R.string.prescription_details) - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = header, - onBack = onCancel - ) - }, - ) { innerPadding -> - Box( - Modifier - .padding(innerPadding) - .fillMaxSize() + prescription?.let { pres -> + + val navController = rememberNotSaveableNavController() + NavHost( + navController = navController, + startDestination = PrescriptionDetailsNavigationScreens.Overview.route ) { - state?.let { - NavigationAnimation(mode = navigationMode) { - PrescriptionDetails( - viewModel = viewModel, - mainNavController = mainNavController, - state = it, - lowDetailRedeemEvents = lowDetailRedeemEvents, - onCancel = onCancel - ) - } + composable(PrescriptionDetailsNavigationScreens.Overview.route) { + PrescriptionDetailsWithScaffold( + prescription = pres, + viewModel = viewModel, + navController = navController, + onClickMedication = { + selectedMedication = it + navController.navigate(PrescriptionDetailsNavigationScreens.Medication.path()) + }, + onBack = onBack + ) + } + composable(PrescriptionDetailsNavigationScreens.MedicationOverview.route) { + MedicationOverviewScreen( + prescription = pres as PrescriptionData.Synced, + onClickMedication = { + selectedMedication = it + navController.navigate(PrescriptionDetailsNavigationScreens.Medication.path()) + }, + onBack = onBack + ) + } + composable(PrescriptionDetailsNavigationScreens.Medication.route) { + SyncedMedicationDetailScreen( + prescription = pres as PrescriptionData.Synced, + medication = requireNotNull(selectedMedication), + onClickIngredient = { + selectedIngredient = it + navController.navigate(PrescriptionDetailsNavigationScreens.Ingredient.path()) + }, + onBack = { + navController.popBackStack() + } + ) + } + composable(PrescriptionDetailsNavigationScreens.Ingredient.route) { + IngredientScreen( + ingredient = requireNotNull(selectedIngredient), + onBack = { + navController.popBackStack() + } + ) + } + composable(PrescriptionDetailsNavigationScreens.Patient.route) { + PatientScreen( + prescription = pres as PrescriptionData.Synced, + onBack = { + navController.popBackStack() + } + ) + } + composable(PrescriptionDetailsNavigationScreens.Prescriber.route) { + PrescriberScreen( + prescription = pres as PrescriptionData.Synced, + onBack = { + navController.popBackStack() + } + ) + } + composable(PrescriptionDetailsNavigationScreens.Accident.route) { + AccidentInformation( + prescription = pres as PrescriptionData.Synced, + onBack = { navController.popBackStack() } + ) + } + composable(PrescriptionDetailsNavigationScreens.Organization.route) { + OrganizationScreen( + prescription = pres as PrescriptionData.Synced, + onBack = { navController.popBackStack() } + ) + } + composable(PrescriptionDetailsNavigationScreens.TechnicalInformation.route) { + TechnicalInformation( + prescription = pres, + onBack = { navController.popBackStack() } + ) } } } } -@OptIn(ExperimentalAnimationApi::class) +@OptIn(ExperimentalMaterialApi::class) @Composable -private fun PrescriptionDetails( - modifier: Modifier = Modifier, - mainNavController: NavController, +private fun PrescriptionDetailsWithScaffold( + prescription: PrescriptionData.Prescription, viewModel: PrescriptionDetailsViewModel, - state: UIPrescriptionDetail, - lowDetailRedeemEvents: List, - onCancel: () -> Unit + navController: NavHostController, + onClickMedication: (PrescriptionData.Medication) -> Unit, + onBack: () -> Unit ) { - var showMore by remember { mutableStateOf(false) } + val scaffoldState = rememberScaffoldState() val listState = rememberLazyListState() + // val shareHandler = rememberSharePrescriptionController() - val hintPadding = Modifier.padding(start = PaddingDefaults.Medium, end = PaddingDefaults.Medium) - - val isSubstituted = (state as? UIPrescriptionDetailSynced)?.let { - it.medicationDispense != null && it.medication.uniqueIdentifier != it.medicationDispense.uniqueIdentifier - } ?: false + val sheetState = rememberModalBottomSheetState( + ModalBottomSheetValue.Hidden, + confirmStateChange = { it != ModalBottomSheetValue.HalfExpanded } + ) - LazyColumn( - state = listState, - modifier = modifier - .fillMaxSize(), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) - ) { - if ((state as? UIPrescriptionDetailSynced)?.medicationRequest?.emergencyFee == true && state.redeemedOn == null) { - item { - EmergencyServiceCard() - } - } + val coroutineScope = rememberCoroutineScope() - // low detail redeemed information - if (state is UIPrescriptionDetailScanned && (state as? UIPrescriptionDetailScanned)?.redeemedOn != null) { - item { - Spacer16() - HintCard( - modifier = hintPadding, - image = { - HintSmallImage( - painterResource(R.drawable.health_card_hint_blue), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.scanned_prescription_detail_redeemed_hint_header)) }, - body = { Text(stringResource(R.string.scanned_prescription_detail_redeemed_hint_info)) }, - action = { - HintTextActionButton(stringResource(R.string.scanned_prescription_detail_redeemed_hint_connect)) { - mainNavController.navigate(MainNavigationScreens.CardWall.path()) - } - } - ) - } - } + var infoBottomSheetContent: PrescriptionDetailBottomSheetContent? by remember { mutableStateOf(null) } - // low detail information - if (state is UIPrescriptionDetailScanned && (state as? UIPrescriptionDetailScanned)?.redeemedOn == null) { - item { - Spacer16() - HintCard( - modifier = hintPadding, - image = { - HintSmallImage( - painterResource(R.drawable.information), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.scanned_prescription_detail_info_hint_header)) }, - body = { Text(stringResource(R.string.scanned_prescription_detail_info_hint_info)) }, - action = { - HintTextLearnMoreButton() - } - ) - } + LaunchedEffect(infoBottomSheetContent) { + if (infoBottomSheetContent != null) { + sheetState.show() + } else { + sheetState.hide() } - - state.bitmapMatrix?.let { bitMatrix -> - if (state.redeemedOn == null) { - item { - DataMatrixCode(bitMatrix) + } + ModalBottomSheetLayout( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Screen), + sheetState = sheetState, + sheetContent = { + Box( + Modifier + .heightIn(min = 56.dp) + .navigationBarsPadding() + ) { + infoBottomSheetContent?.let { + PrescriptionDetailInfoSheetContent(infoContent = it) } } - } - - item { - val prescriptionName = when (state) { - is UIPrescriptionDetailScanned -> stringResource( - id = R.string.scanned_prescription_placeholder_name, - state.number - ) - is UIPrescriptionDetailSynced -> when (isSubstituted) { - true -> state.medicationDispense?.text ?: MISSING_VALUE - false -> state.medication.text ?: MISSING_VALUE + }, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) { + AnimatedElevationScaffold( + scaffoldState = scaffoldState, + listState = listState, + onBack = onBack, + topBarTitle = stringResource(R.string.prescription_details), + navigationMode = NavigationBarMode.Close, + snackbarHost = { SnackbarHost(it, modifier = Modifier.navigationBarsPadding()) }, + actions = { + // if (prescription.accessCode != null) { + // IconButton(onClick = { + // shareHandler.share(taskId = prescription.taskId, prescription.accessCode!!) + // }) { + // Icon(Icons.Rounded.Share, null, tint = AppTheme.colors.primary700) + // } + // } + + val context = LocalContext.current + val authenticator = LocalAuthenticator.current + val deletePrescriptionsHandle = remember { + DeletePrescriptions( + bridge = viewModel, + authenticator = authenticator + ) } - else -> MISSING_VALUE - } - Header( - text = prescriptionName - ) - } - - val taskId = state.taskId - when (state) { - is UIPrescriptionDetailSynced -> item { - FullDetailSecondHeader(state) { - mainNavController.navigate( - MainNavigationScreens.Pharmacies.path( - taskIds = TaskIds( - listOf(taskId) - ) - ) + DeleteAction(prescription) { + val deleteState = deletePrescriptionsHandle.deletePrescription( + profileId = prescription.profileId, + taskId = prescription.taskId ) - } - } - is UIPrescriptionDetailScanned -> item { - LowDetailRedeemHeader(state) { redeem, all, protocolText -> - viewModel.onSwitchRedeemed(state.taskId, redeem, all, protocolText) - } - } - } - if ((state as? UIPrescriptionDetailSynced)?.medicationRequest?.substitutionAllowed == true && state.redeemedOn == null) { - item { - SubstitutionAllowed() - } - } + when (deleteState) { + is PrescriptionServiceErrorState -> { + coroutineScope.launch { + deleteErrorMessage(context, deleteState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) + } + } + } - item { - Column { - if (state is UIPrescriptionDetailScanned) { - ProtocolScanned(state, lowDetailRedeemEvents) - } - if (state is UIPrescriptionDetailSynced) { - MedicationInformation(state, isSubstituted) - if (isSubstituted) { - WasSubstitutedHint() + is DeletePrescriptions.State.Deleted -> onBack() } - DosageInformation(state, isSubstituted) - HealthPortalLink() - PatientInformation(state.patient, state.insurance) } } - } - - item { - Column(modifier = Modifier.fillMaxSize()) { - Button( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = 40.dp, bottom = 40.dp) - .toggleable( - value = showMore, - onValueChange = { showMore = it }, - role = Role.Checkbox, - ), - colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.neutral050, - contentColor = AppTheme.colors.primary700 - ), - onClick = { showMore = !showMore } - ) { - Text( - stringResource( - when (showMore) { - true -> R.string.pres_detail_show_less - false -> R.string.pres_detail_show_more + ) { innerPadding -> + when (prescription) { + is PrescriptionData.Synced -> + SyncedPrescriptionOverview( + navController = navController, + listState = listState, + prescription = prescription, + onSelectMedication = onClickMedication, + onShowInfo = { + infoBottomSheetContent = it + coroutineScope.launch { + sheetState.show() } - ).uppercase(Locale.getDefault()) - ) - Icon( - imageVector = when (showMore) { - true -> Icons.Rounded.KeyboardArrowUp - false -> Icons.Rounded.KeyboardArrowDown - }, - contentDescription = null + } ) - } - AnimatedVisibility( - visibleState = remember { MutableTransitionState(false) }.apply { - targetState = showMore - }, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - Column { - if (state is UIPrescriptionDetailSynced) { - PractitionerInformation(state.practitioner) - OrganizationInformation(state.organization) - AccidentInformation(state.medicationRequest) - } - TechnicalPrescriptionInformation( - accessCode = state.accessCode, - taskId = state.taskId - ) - - val context = LocalContext.current - val accessInfoText = stringResource(R.string.logout_delete_no_access) - val prescriptionInProgressText = - stringResource(R.string.logout_delete_in_progress) - - DeleteButton(state is UIPrescriptionDetailSynced) { - viewModel.deletePrescription( - state.taskId, - state is UIPrescriptionDetailSynced - ).apply { - if (this is Result.Error) { - if (this.exception is ApiCallException) { - if (this.exception.response.code() == FORBIDDEN) { - createToastShort(context, prescriptionInProgressText) - } else { - createToastShort(context, accessInfoText) - } - } else { - createToastShort(context, accessInfoText) - } - } else { - onCancel() - } + is PrescriptionData.Scanned -> + ScannedPrescriptionOverview( + navController = navController, + listState = listState, + prescription = prescription, + onSwitchRedeemed = { + viewModel.redeemScannedTask(taskId = prescription.taskId, redeem = it) + }, + onShowInfo = { + infoBottomSheetContent = it + coroutineScope.launch { + sheetState.show() } } - } - } + ) } } } - - val scrollOffset = with(LocalDensity.current) { - 200.dp.toPx() - } - - LaunchedEffect(showMore) { - if (showMore) { - delay(100) - listState.animateScrollBy(scrollOffset) - } - } -} - -@Composable -private fun DataMatrixCode(bitmapMatrix: BitMatrixCode) { - Surface( - shape = RoundedCornerShape(PaddingDefaults.Medium / 2), - border = BorderStroke(1.dp, AppTheme.colors.neutral300), - modifier = Modifier.padding(16.dp) - ) { - DataMatrixCode( - bitmapMatrix, - modifier = Modifier - .aspectRatio(1.0f) - ) - } } @Composable -private fun DeleteButton(isSyncedPrescription: Boolean, onClickDelete: () -> Unit) { +private fun DeleteAction( + prescription: PrescriptionData.Prescription, + onClickDelete: suspend () -> Unit +) { var showDeletePrescriptionDialog by remember { mutableStateOf(false) } + var deletionInProgress by remember { mutableStateOf(false) } + + val coroutineScope = rememberCoroutineScope() + val mutex = MutatorMutex() - val deleteText = when (isSyncedPrescription) { - true -> stringResource(R.string.pres_detail_delete) - false -> stringResource(R.string.scanned_prescription_delete) + var dropdownExpanded by remember { mutableStateOf(false) } + + val isDeletable by derivedStateOf { + (prescription as? PrescriptionData.Synced)?.isDeletable ?: true } - Button( - onClick = { showDeletePrescriptionDialog = true }, - modifier = Modifier - .padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = PaddingDefaults.Medium * 2, - bottom = PaddingDefaults.Medium - ) - .fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.red600, - contentColor = AppTheme.colors.neutral000 - ) + IconButton( + onClick = { dropdownExpanded = true }, + modifier = Modifier.testTag(TestTag.Prescriptions.Details.MoreButton) ) { - Text( - deleteText.uppercase(Locale.getDefault()), - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = PaddingDefaults.Medium / 2, - bottom = PaddingDefaults.Medium / 2 + Icon(Icons.Rounded.MoreVert, null, tint = AppTheme.colors.neutral600) + } + DropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + offset = DpOffset(24.dp, 0.dp) + ) { + DropdownMenuItem( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.DeleteButton), + enabled = isDeletable, + onClick = { + dropdownExpanded = false + showDeletePrescriptionDialog = true + } + ) { + Text( + text = stringResource(R.string.pres_detail_dropdown_delete), + color = AppTheme.colors.red600 ) - ) + } } if (showDeletePrescriptionDialog) { @@ -513,660 +413,520 @@ private fun DeleteButton(isSyncedPrescription: Boolean, onClickDelete: () -> Uni info = info, cancelText = cancelText, actionText = actionText, + enabled = !deletionInProgress, onCancel = { showDeletePrescriptionDialog = false }, onClickAction = { - onClickDelete() - showDeletePrescriptionDialog = false + coroutineScope.launch { + mutex.mutate { + try { + deletionInProgress = true + onClickDelete() + } finally { + showDeletePrescriptionDialog = false + deletionInProgress = false + } + } + } } ) } } +@Suppress("LongMethod") @Composable -private fun FullDetailSecondHeader( - prescriptionDetail: UIPrescriptionDetailSynced, - onClickRedeem: () -> Unit +private fun SyncedPrescriptionOverview( + navController: NavController, + listState: LazyListState, + prescription: PrescriptionData.Synced, + onSelectMedication: (PrescriptionData.Medication) -> Unit, + onShowInfo: (PrescriptionDetailBottomSheetContent) -> Unit ) { - val dtFormatter = - remember(LocalConfiguration.current) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } - - val text = - if (prescriptionDetail.medicationDispense != null) { - stringResource( - id = R.string.pres_detail_medication_redeemed_on, - prescriptionDetail.medicationDispense.whenHandedOver.format(dtFormatter) - ) - } else if (prescriptionDetail.taskStatus == TaskStatus.InProgress) { - stringResource( - id = R.string.pres_detail_medication_in_progress, - ) + val noValueText = stringResource(R.string.pres_details_no_value) + + Column { + val colPadding = if (prescription.isIncomplete) { + PaddingValues() } else { - prescriptionDetail.redeemUntil?.let { expiryDate -> - prescriptionDetail.acceptUntil?.let { acceptDate -> - expiryOrAcceptString( - expiryDate = expiryDate, - acceptDate = acceptDate, - nowInEpochDays = LocalDate.now().toEpochDay() - ) - } - } - } ?: "" - Text( - text = text, - style = AppTheme.typography.body2l, - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium - ) - ) - Spacer4() - - var redeemable by remember { mutableStateOf(false) } - prescriptionDetail.redeemUntil.let { - if ( - it != null && it.toEpochDay() >= LocalDate.now().toEpochDay() && - prescriptionDetail.accessCode != null && - prescriptionDetail.taskStatus == TaskStatus.Ready - ) { - redeemable = true + WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() } - } - - if (prescriptionDetail.redeemedOn == null && redeemable) { - Button( - onClick = { onClickRedeem() }, - shape = RoundedCornerShape(8.dp), + LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() - .heightIn(min = 46.dp) - .padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = 24.dp, - bottom = PaddingDefaults.Medium - ) + .weight(1f) + .testTag(TestTag.Prescriptions.Details.Content), + contentPadding = colPadding ) { - Text( - stringResource(R.string.pres_detail_medication_redeem_button_text).uppercase(Locale.getDefault()), - modifier = Modifier.padding( - horizontal = PaddingDefaults.Medium, - vertical = PaddingDefaults.Small + // prescription name + // prescription kind + // prescription state + item { + SyncedHeader( + prescription = prescription, + onShowInfo = onShowInfo ) - ) - } - } -} + } -@Composable -private fun LowDetailRedeemHeader( - prescriptionDetail: UIPrescriptionDetailScanned, - onSwitchRedeemed: (redeem: Boolean, all: Boolean, protocolText: String) -> Unit -) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(start = 16.dp, end = 16.dp, top = 32.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { + item { + val text = when { + prescription.medicationRequest.additionalFee == SyncedTaskData.AdditionalFee.Exempt -> + stringResource(R.string.pres_detail_no) - RedeemedButton( - prescriptionDetail.redeemedOn != null, - prescriptionDetail.unRedeemMorePossible, - onSwitchRedeemed - ) - } + prescription.medicationRequest.additionalFee == SyncedTaskData.AdditionalFee.NotExempt -> + stringResource(R.string.pres_detail_yes) - // mark as redeemed information hint - if (prescriptionDetail.redeemedOn == null) { - Spacer16() - HintCard( - image = { - HintSmallImage( - painterResource(R.drawable.pharmacist_hint), - innerPadding = it + else -> noValueText + } + Label( + text = text, + label = stringResource(R.string.pres_details_additional_fee), + onClick = { + when { + prescription.medicationRequest.additionalFee == SyncedTaskData.AdditionalFee.NotExempt -> + onShowInfo(PrescriptionDetailBottomSheetContent.AdditionalFeeNotExempt) + + prescription.medicationRequest.additionalFee == SyncedTaskData.AdditionalFee.Exempt -> + onShowInfo(PrescriptionDetailBottomSheetContent.AdditionalFeeExempt) + + else -> {} + } + } ) - }, - title = { Text(stringResource(R.string.scanned_prescription_detail_hint_header)) }, - body = { Text(stringResource(R.string.scanned_prescription_detail_hint_info)) }, - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) - ) - } -} - -@Composable -private fun RedeemedButton( - redeemed: Boolean, - unRedeemMorePossible: Boolean, - onSwitchRedeemed: (redeem: Boolean, all: Boolean, protocolText: String) -> Unit -) { - - val context = LocalContext.current - var currentRedeemed by remember { mutableStateOf(redeemed) } - var infoText by remember { mutableStateOf("") } - - val redeemedInfo = stringResource(R.string.prescription_detail_redeemed) - val unRedeemedInfo = stringResource(R.string.prescription_detail_un_redeemed) - - DisposableEffect(currentRedeemed) { - infoText = if (currentRedeemed) { - unRedeemedInfo - } else { - redeemedInfo - } - onDispose { } - } - - var showUnRedeemDialog by remember { mutableStateOf(false) } - val redeemProtocolText = stringResource(R.string.redeem_protocol_text) - val unRedeemProtocolText = stringResource(R.string.un_redeem_protocol_text) - - if (showUnRedeemDialog) { - UnRedeemPrescriptionDialog( - onSwitchRedeemed = { redeem, all, protocolText -> - onSwitchRedeemed(redeem, all, protocolText) - showUnRedeemDialog = false - currentRedeemed = !currentRedeemed - createToastShort(context, infoText) - }, - ) - } - - val buttonColors = if (currentRedeemed) { - ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.neutral050, - contentColor = AppTheme.colors.primary700 - ) - } else { - ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.primary600, - contentColor = AppTheme.colors.neutral000 - ) - } - - val buttonText = if (currentRedeemed) { - stringResource(R.string.scanned_prescription_details_mark_as_unredeemed) - } else { - stringResource(R.string.scanned_prescription_details_mark_as_redeemed) - } - - val protocolText = if (currentRedeemed) { - unRedeemProtocolText - } else { - redeemProtocolText - } - - Button( - onClick = { - if (currentRedeemed && unRedeemMorePossible) { - showUnRedeemDialog = true - } else { - onSwitchRedeemed(!currentRedeemed, false, protocolText) - currentRedeemed = !currentRedeemed - createToastShort(context, infoText) } - }, - colors = buttonColors, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 46.dp) - ) { - Text( - buttonText.uppercase(Locale.getDefault()) - ) - } -} -@Composable -private fun UnRedeemPrescriptionDialog( - onSwitchRedeemed: (redeem: Boolean, all: Boolean, protocol: String) -> Unit, -) { - val unRedeemProtocolText = stringResource(R.string.un_redeem_protocol_text) - - AlertDialog( - onDismissRequest = { onSwitchRedeemed(false, false, unRedeemProtocolText) }, - text = { - Text( - stringResource(R.string.pres_detail_un_redeem_msg) - ) - }, - buttons = { - TextButton( - onClick = { - onSwitchRedeemed( - false, - false, - unRedeemProtocolText - ) - } - ) { - Text(stringResource(R.string.pres_detail_un_redeem_selected).uppercase(Locale.getDefault())) - } - TextButton( - onClick = { - onSwitchRedeemed( - false, - true, - unRedeemProtocolText + if (prescription.medicationRequest.emergencyFee != null) { + item { + val text = if (prescription.medicationRequest.emergencyFee) { + stringResource(R.string.pres_detail_yes) + } else { + stringResource(R.string.pres_detail_no) + } + Label( + text = text, + label = stringResource(R.string.pres_details_emergency_fee), + onClick = { + onShowInfo(PrescriptionDetailBottomSheetContent.EmergencyFee) + } ) } - ) { - Text(stringResource(R.string.pres_detail_un_redeem_all).uppercase(Locale.getDefault())) } - }, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) -} -@Composable -private fun MedicationInformation( - state: UIPrescriptionDetailSynced, - isSubstituted: Boolean -) { + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.MedicationButton), + text = prescription.name ?: noValueText, + label = stringResource(R.string.pres_details_medication), + onClick = { + if (!prescription.isDispensed) { + onSelectMedication(PrescriptionData.Medication.Request(prescription.medicationRequest)) + } else { + navController.navigate(PrescriptionDetailsNavigationScreens.MedicationOverview.path()) + } + } + ) + } - val medicationType = if (isSubstituted) { - state.medicationDispense?.type?.let { codeToDosageFormMapping[it] } - ?.let { stringResource(it) } ?: MISSING_VALUE - } else { - state.medication.type?.let { stringResource(it) } ?: MISSING_VALUE - } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.PatientButton), + text = prescription.patient.name ?: noValueText, + label = stringResource(R.string.pres_detail_patient_header), + onClick = { + navController.navigate(PrescriptionDetailsNavigationScreens.Patient.path()) + } + ) + } - val uniqueIdentifier = if (isSubstituted) { - state.medicationDispense?.uniqueIdentifier ?: MISSING_VALUE - } else { - state.medication.uniqueIdentifier ?: MISSING_VALUE - } + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.PrescriberButton), + text = prescription.practitioner.name ?: noValueText, + label = stringResource(R.string.pres_detail_practitioner_header), + onClick = { + navController.navigate(PrescriptionDetailsNavigationScreens.Prescriber.path()) + } + ) + } - SubHeader( - text = stringResource(id = R.string.pres_detail_medication_header) - ) + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.OrganizationButton), + text = prescription.organization.name ?: noValueText, + label = stringResource(R.string.pres_detail_organization_header), + onClick = { + navController.navigate(PrescriptionDetailsNavigationScreens.Organization.path()) + } + ) + } - Label( - text = medicationType, - label = stringResource(id = R.string.pres_detail_medication_label_dosage_form) - ) + item { + Label( + text = stringResource(R.string.pres_detail_accident_header), + onClick = { + navController.navigate(PrescriptionDetailsNavigationScreens.Accident.path()) + } + ) + } - Label( - text = if (state.medication.normSize != null) { - if (state.medication.normSize.text != null) { - "${state.medication.normSize.code} - ${stringResource(state.medication.normSize.text)}" - } else { - state.medication.normSize.code + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.TechnicalInformationButton), + text = stringResource(R.string.pres_detail_technical_information), + onClick = { + navController.navigate(PrescriptionDetailsNavigationScreens.TechnicalInformation.path()) + } + ) } - } else { - MISSING_VALUE - }, - label = stringResource(id = R.string.pres_detail_medication_label_normsize) - ) - Label( - text = uniqueIdentifier, - label = stringResource(id = R.string.pres_detail_medication_label_id) - ) -} + item { + HealthPortalLink( + Modifier.padding( + horizontal = PaddingDefaults.Medium, + vertical = PaddingDefaults.XXLarge + ) + ) + } + } -@Composable -private fun WasSubstitutedHint() { - - HintCard( - modifier = Modifier.padding(PaddingDefaults.Medium), - properties = HintCardDefaults.properties( - backgroundColor = AppTheme.colors.red100, - contentColor = AppTheme.colors.neutral999, - border = BorderStroke(0.0.dp, AppTheme.colors.neutral300), - elevation = 0.dp - ), - image = { - HintSmallImage( - painterResource(R.drawable.medical_hand_out_circle_red), - innerPadding = it + if (prescription.isIncomplete) { + FailureBanner( + Modifier + .fillMaxWidth() + .navigationBarsPadding(), + prescription ) - }, - title = { Text(stringResource(R.string.pres_detail_substituted_header)) }, - body = { Text(stringResource(R.string.pres_detail_substituted_info)) } - ) -} - -@Composable -private fun ColumnScope.DosageInformation( - state: UIPrescriptionDetailSynced, - isSubstituted: Boolean -) { - val infoText = if (isSubstituted) { - state.medicationDispense?.dosageInstruction - ?: stringResource(id = R.string.pres_detail_dosage_default_info) - } else { - state.medicationRequest.dosageInstruction - ?: stringResource(id = R.string.pres_detail_dosage_default_info) + } } - - SubHeader( - text = stringResource(id = R.string.pres_detail_dosage_header) - ) - HintCard( - modifier = Modifier.padding(start = PaddingDefaults.Medium, end = PaddingDefaults.Medium), - image = { HintSmallImage(painterResource(R.drawable.doctor_circle), innerPadding = it) }, - title = null, - body = { Text(infoText) }, - ) -} - -@Composable -fun ColumnScope.HealthPortalLink() { - Spacer16() - Text( - modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), - text = stringResource(id = R.string.pres_detail_health_portal_description), - style = MaterialTheme.typography.body2, - color = AppTheme.typographyColors.body2l - ) - Spacer8() - val linkInfo = stringResource(id = R.string.pres_detail_health_portal_description_url_info) - val link = stringResource(id = R.string.pres_detail_health_portal_description_url) - val uriHandler = LocalUriHandler.current - val annotatedLink = - annotatedLinkStringLight(link, linkInfo) - ClickableText( - text = annotatedLink, - onClick = { - annotatedLink - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - }, - modifier = Modifier - .align(Alignment.End) - .padding(end = PaddingDefaults.Medium) - ) } @Composable -private fun PatientInformation( - patient: PatientDetail, - insurance: InsuranceCompanyDetail +private fun FailureBanner( + modifier: Modifier, + prescription: PrescriptionData.Synced ) { - val dtFormatter = - remember(LocalConfiguration.current) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } - - SubHeader( - text = stringResource(id = R.string.pres_detail_patient_header) - ) + val mailAddress = stringResource(R.string.settings_contact_mail_address) + val subject = stringResource(R.string.settings_feedback_mail_subject) - Label( - text = patient.name ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_patient_label_name) - ) - - Label( - text = patient.address ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_patient_label_address) - ) - - Label( - text = patient.birthdate?.format(dtFormatter) ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_patient_label_birthdate) - ) - - Label( - text = insurance.name ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_patient_label_insurance) - ) - - Label( - text = insurance.status?.let { stringResource(it) } ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_patient_label_member_status) - ) - - Label( - text = patient.insuranceIdentifier ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_patient_label_insurance_id) - ) + val context = LocalContext.current + Row( + modifier + .fillMaxWidth() + .background(AppTheme.colors.neutral050) + .padding(PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.prescription_failure_info), + style = AppTheme.typography.body2, + modifier = Modifier.weight(1f) + ) + SpacerMedium() + PrimaryButtonTiny( + onClick = { + val body = """ + PVS ID: ${prescription.task.pvsIdentifier} + + ${prescription.failureToReport} + """.trimIndent() + + context.handleIntent( + provideEmailIntent( + address = mailAddress, + body = body, + subject = subject + ) + ) + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = AppTheme.colors.red600, + contentColor = AppTheme.colors.neutral000 + ) + ) { + Text(stringResource(R.string.report_prescription_failure)) + } + } } @Composable -private fun PractitionerInformation( - practitioner: PractitionerDetail +fun SyncedHeader( + prescription: PrescriptionData.Synced, + onShowInfo: (PrescriptionDetailBottomSheetContent) -> Unit ) { - SubHeader( - text = stringResource(id = R.string.pres_detail_practitioner_header) - ) - - Label( - text = practitioner.name ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_practitioner_label_name) - ) - - Label( - text = practitioner.qualification ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_practitioner_label_qualification) - ) - - Label( - text = practitioner.practitionerIdentifier ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_practitioner_label_id) - ) -} + Column( + Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + prescription.name ?: stringResource(R.string.prescription_medication_default_name), + style = AppTheme.typography.h5, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + when { + prescription.isIncomplete -> { + SpacerShortMedium() + FailureDetailsStatusChip( + onClick = { onShowInfo(PrescriptionDetailBottomSheetContent.Failure) } + ) + } -@Composable -private fun OrganizationInformation( - organization: OrganizationDetail -) { - SubHeader( - text = stringResource(id = R.string.pres_detail_organization_header) - ) + prescription.isDirectAssignment -> { + SpacerShortMedium() + DirectAssignmentChip( + onClick = { onShowInfo(PrescriptionDetailBottomSheetContent.DirectAssignment) } + ) + } - Label( - text = organization.name ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_organization_label_name) - ) + prescription.isSubstitutionAllowed -> { + SpacerShortMedium() + SubstitutionAllowedChip( + onClick = { onShowInfo(PrescriptionDetailBottomSheetContent.SubstitutionAllowed) } + ) + } + } - Label( - text = organization.address ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_organization_label_address) - ) + SpacerShortMedium() - Label( - text = organization.uniqueIdentifier ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_organization_label_id) - ) - - Label( - text = organization.phone ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_organization_label_telephone) - ) + val onClick = when { + !prescription.isDirectAssignment && + ( + prescription.state is SyncedTaskData.SyncedTask.Ready || + prescription.state is SyncedTaskData.SyncedTask.LaterRedeemable + ) -> { + { onShowInfo(PrescriptionDetailBottomSheetContent.HowLongValid(prescription)) } + } - Label( - text = organization.mail ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_organization_label_email) - ) + else -> null + } + SyncedStatus( + prescription = prescription, + onClick = onClick + ) + SpacerXLarge() + } } @Composable -private fun AccidentInformation( - medicationRequest: MedicationRequestDetail +fun SyncedStatus( + modifier: Modifier = Modifier, + prescription: PrescriptionData.Synced, + onClick: (() -> Unit)? = null ) { - val dtFormatter = - remember(LocalConfiguration.current) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } - - SubHeader( - text = stringResource(id = R.string.pres_detail_accident_header) - ) - - Label( - text = medicationRequest.dateOfAccident?.format(dtFormatter) - ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_accident_label_date) - ) + val clickableModifier = if (onClick != null) { + Modifier + .clickable(role = Role.Button, onClick = onClick) + .padding(start = PaddingDefaults.Tiny) + } else { + Modifier + } - Label( - text = medicationRequest.location ?: MISSING_VALUE, - label = stringResource(id = R.string.pres_detail_accident_label_location) - ) + Row( + modifier = modifier + .clip(CircleShape) + .then(clickableModifier), + verticalAlignment = Alignment.CenterVertically + ) { + if (prescription.isDirectAssignment) { + Text( + stringResource(R.string.pres_details_direct_assignment_state), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + stringResource(R.string.pres_details_direct_assignment_state) + } else { + prescriptionStateInfo(prescription.state) + } + if (onClick != null) { + Spacer(Modifier.padding(2.dp)) + Icon( + Icons.Rounded.KeyboardArrowRight, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + } + } } @Composable -private fun ProtocolScanned( - uiPrescriptionDetail: UIPrescriptionDetailScanned, - lowDetailRedeemEvents: List +private fun ScannedPrescriptionOverview( + navController: NavController, + listState: LazyListState, + prescription: PrescriptionData.Scanned, + onSwitchRedeemed: (redeemed: Boolean) -> Unit, + onShowInfo: (PrescriptionDetailBottomSheetContent) -> Unit ) { - val firstScan = stringResource( - R.string.scanned_prescription_detail_protocol_scanned_at, - uiPrescriptionDetail.formattedScannedInfo(stringResource(R.string.at)) - ) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize(), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + // prescription name + // prescription kind + // prescription state + item { + Column( + Modifier + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.pres_details_scanned_prescription), + style = AppTheme.typography.h5, + textAlign = TextAlign.Center + ) + SpacerShortMedium() + ScannedChip(onClick = { onShowInfo(PrescriptionDetailBottomSheetContent.Scanned) }) + SpacerShortMedium() + val date = dateWithIntroductionString(R.string.prs_low_detail_scanned_on, prescription.scannedOn) + Text(date, style = AppTheme.typography.body2l) + } + } - SubHeader( - text = stringResource(id = R.string.scanned_prescription_detail_protocol_header) - ) + item { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) + ) { + SpacerXLarge() + RedeemedButton( + modifier = Modifier.align(Alignment.CenterHorizontally), + redeemed = prescription.isRedeemed, + onSwitchRedeemed = onSwitchRedeemed + ) + SpacerXXLarge() + } + } - Label( - text = firstScan, - label = stringResource(id = R.string.scanned_prescription_detail_protocol_scanned_label) - ) + item { + Label( + text = stringResource(R.string.pres_detail_technical_information), + onClick = { + navController.navigate(PrescriptionDetailsNavigationScreens.TechnicalInformation.path()) + } + ) + } - lowDetailRedeemEvents.map { - Label( - text = phrasedDateString(it.timestamp.toLocalDateTime()), - label = it.text - ) + item { + HealthPortalLink(Modifier.padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.XXLarge)) + } } } @Composable -private fun TechnicalPrescriptionInformation(accessCode: String?, taskId: String) { - SubHeader(stringResource(R.string.pres_detail_technical_information)) - - if (accessCode != null) { - Label( - text = accessCode, - label = stringResource(id = R.string.access_code) - ) +private fun RedeemedButton( + modifier: Modifier, + redeemed: Boolean, + onSwitchRedeemed: (redeemed: Boolean) -> Unit +) { + val buttonText = if (redeemed) { + stringResource(R.string.scanned_prescription_details_mark_as_unredeemed) + } else { + stringResource(R.string.scanned_prescription_details_mark_as_redeemed) } - Label( - text = taskId, - label = stringResource(id = R.string.task_id) - ) + PrimaryButtonSmall( + onClick = { + onSwitchRedeemed(!redeemed) + }, + modifier = modifier + ) { + Text(buttonText) + } } @OptIn(ExperimentalFoundationApi::class) @Composable -private fun Label( - text: String, - label: String +fun Label( + modifier: Modifier = Modifier, + text: String?, + label: String? = null, + onClick: (() -> Unit)? = null ) { val clipboardManager = LocalClipboardManager.current val context = LocalContext.current - Column( - modifier = Modifier - .combinedClickable( - onClick = {}, - onLongClick = { - clipboardManager.setText(AnnotatedString(text)) - Toast - .makeText(context, "$label $text", Toast.LENGTH_SHORT) - .show() - } - ) - .padding(PaddingDefaults.Medium) - .fillMaxWidth() - ) { - Text( - text = text, - style = MaterialTheme.typography.body1 - ) - Spacer4() - Text( - text = label, - style = MaterialTheme.typography.body2, - color = AppTheme.typographyColors.body2l - ) + val verticalPadding = if (label != null) { + PaddingDefaults.ShortMedium + } else { + PaddingDefaults.Medium } -} -@Composable -private fun Header( - text: String -) = Text( - text = text, - style = MaterialTheme.typography.h6, - fontWeight = FontWeight(500), - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = PaddingDefaults.Medium * 1.5f - ) -) + val noValueText = stringResource(R.string.pres_details_no_value) -@Composable -private fun SubHeader( - text: String -) = - Text( - text = text, - style = MaterialTheme.typography.subtitle1, - fontWeight = FontWeight(500), - modifier = Modifier.padding( - top = 40.dp, - end = PaddingDefaults.Medium, - start = PaddingDefaults.Medium, - bottom = PaddingDefaults.Medium - ) - ) - -@Composable -private fun EmergencyServiceCard() { - Card( - modifier = Modifier - .padding( - start = PaddingDefaults.Medium, - top = PaddingDefaults.Medium, - end = PaddingDefaults.Medium + Row( + modifier = modifier + .combinedClickable( + onClick = { + onClick?.invoke() + }, + onLongClick = { + if (text != null) { + clipboardManager.setText(AnnotatedString(text)) + Toast + .makeText(context, "$label $text", Toast.LENGTH_SHORT) + .show() + } + }, + role = Role.Button ) - .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium, vertical = verticalPadding) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Row { - Image( - painterResource(R.drawable.pharmacist), - null, - alignment = Alignment.BottomStart + Column(Modifier.weight(1f)) { + Text( + text = text ?: noValueText, + style = AppTheme.typography.body1 ) - Column { - Text( - stringResource(R.string.pres_detail_noctu_header), - style = MaterialTheme.typography.subtitle1 - ) + if (label != null) { Text( - stringResource(R.string.pres_detail_noctu_info), - style = MaterialTheme.typography.body2 + text = label, + style = AppTheme.typography.body2l ) } } + if (onClick != null) { + SpacerMedium() + Icon(Icons.Rounded.KeyboardArrowRight, null, tint = AppTheme.colors.neutral400) + } } } @Composable -fun SubstitutionAllowed() { - HintCard( - modifier = Modifier.padding(PaddingDefaults.Medium), - properties = HintCardDefaults.properties( - backgroundColor = AppTheme.colors.primary100, - border = BorderStroke(0.0.dp, AppTheme.colors.neutral300), - elevation = 0.dp - ), - image = { - HintSmallImage( - painterResource(R.drawable.pharmacist_circle), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.pres_detail_aut_idem_header)) }, - body = { Text(stringResource(R.string.pres_detail_aut_idem_info)) }, - action = { - HintTextLearnMoreButton() - } - ) +fun HealthPortalLink( + modifier: Modifier +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.pres_detail_health_portal_description), + style = AppTheme.typography.body2l + ) + + val linkInfo = stringResource(R.string.pres_detail_health_portal_description_url_info) + val link = stringResource(R.string.pres_detail_health_portal_description_url) + val uriHandler = LocalUriHandler.current + val annotatedLink = annotatedLinkStringLight(link, linkInfo) + + SpacerSmall() + ClickableText( + text = annotatedLink, + onClick = { + annotatedLink + .getStringAnnotations("URL", it, it) + .firstOrNull()?.let { stringAnnotation -> + uriHandler.openUri(stringAnnotation.item) + } + }, + modifier = Modifier.align(Alignment.End) + ) + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt index d4ab3849..56d908e5 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModel.kt @@ -20,57 +20,27 @@ package de.gematik.ti.erp.app.prescription.detail.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import java.time.OffsetDateTime -import javax.inject.Inject -@HiltViewModel -class PrescriptionDetailsViewModel @Inject constructor( +class PrescriptionDetailsViewModel( val prescriptionUseCase: PrescriptionUseCase, - private val dispatchProvider: DispatchProvider -) : ViewModel() { - suspend fun detailedPrescription(taskId: String): UIPrescriptionDetail = - withContext(dispatchProvider.unconfined()) { prescriptionUseCase.generatePrescriptionDetails(taskId) } + private val dispatchers: DispatchProvider +) : ViewModel(), DeletePrescriptionsBridge { - fun deletePrescription(taskId: String, isRemoteTask: Boolean): Result { - // TODO find better way than runBlocking - return runBlocking(dispatchProvider.io()) { - when (val r = prescriptionUseCase.deletePrescription(taskId, isRemoteTask)) { - is Result.Error -> r - is Result.Success -> { - prescriptionUseCase.deleteLowDetailEvents(taskId) - r - } - } - } - } + suspend fun screenState(taskId: String): Flow = + prescriptionUseCase.generatePrescriptionDetails(taskId) - fun onSwitchRedeemed(taskId: String, redeem: Boolean, all: Boolean, protocolText: String) { - viewModelScope.launch(dispatchProvider.io()) { - prescriptionUseCase.redeem(listOf(taskId), redeem, all) - - prescriptionUseCase.saveLowDetailEvent( - LowDetailEventSimple( - protocolText, - OffsetDateTime.now(), - taskId - ) - ) + fun redeemScannedTask(taskId: String, redeem: Boolean) { + viewModelScope.launch(dispatchers.IO) { + prescriptionUseCase.redeemScannedTask(taskId, redeem) } } - suspend fun loadLowDetailEvents(taskId: String): Flow> { - return prescriptionUseCase.loadLowDetailEvents(taskId) - .flowOn(dispatchProvider.unconfined()) - } + override suspend fun deletePrescription(profileId: ProfileIdentifier, taskId: String): Result = + prescriptionUseCase.deletePrescription(profileId = profileId, taskId = taskId) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SharePrescriptionController.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SharePrescriptionController.kt new file mode 100644 index 00000000..04088841 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SharePrescriptionController.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.LocalIntentHandler +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.ui.TwoDCodeValidator +import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.userauthentication.ui.AuthenticationModeAndMethod +import de.gematik.ti.erp.app.utils.compose.createToastShort +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import org.kodein.di.LazyDelegate +import org.kodein.di.compose.rememberInstance +import java.net.URI +import java.net.URISyntaxException +import java.net.URLEncoder +import java.time.Instant + +private const val ShareBaseUri = "https://das-e-rezept-fuer-deutschland.de/prescription/#" + +@Stable +class SharePrescriptionController( + prescriptionUseCase: LazyDelegate, + private val context: Context, + private val profileId: ProfileIdentifier? = null +) { + enum class HandleResult { + TaskAlreadyExists, TaskSaved, Failure + } + + private val prescriptionUseCase by prescriptionUseCase + + fun share(taskId: String, accessCode: String) { + val uri = URI(ShareBaseUri + URLEncoder.encode("[\"$taskId|$accessCode\"]", Charsets.UTF_8.name())) + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, uri.toString()) + type = "text/plain" + } + context.startActivity(Intent.createChooser(shareIntent, null)) + } + + /** + * Handles an incoming share intent. + * + * URI pattern is .../prescription/#["TASK_ID|ACCESS_CODE"] + */ + suspend fun handle(value: String): HandleResult = + if (value.startsWith(ShareBaseUri)) { + try { + requireNotNull(profileId) { "Profile id not provided" } + + val validUri = URI(value) + val (taskId, accessCode) = validUri.fragment + .removeSurrounding("[\"", "\"]") + .split("|", limit = 2) + + require(TwoDCodeValidator.taskIdPattern.matches(taskId)) { "Invalid task id" } + require(TwoDCodeValidator.accessCodePattern.matches(accessCode)) { "Invalid access code" } + + val allTaskIds = prescriptionUseCase.getAllTasksWithTaskIdOnly().first() + if (taskId in allTaskIds) { + HandleResult.TaskAlreadyExists + } else { + prescriptionUseCase.saveScannedTasks( + profileId = profileId, + tasks = listOf( + ScannedTaskData.ScannedTask( + profileId = profileId, + taskId = taskId, + accessCode = accessCode, + scannedOn = Instant.now(), + redeemedOn = null + ) + ) + ) + + HandleResult.TaskSaved + } + } catch (e: URISyntaxException) { + Napier.e(e) { "Invalid uri" } + HandleResult.Failure + } catch (e: IllegalArgumentException) { + Napier.e(e) { "Invalid task id or access code pattern" } + HandleResult.Failure + } catch (e: IndexOutOfBoundsException) { + Napier.e(e) { "Error parsing input parameter" } + HandleResult.Failure + } + } else { + HandleResult.Failure + } +} + +@Composable +fun rememberSharePrescriptionController(): SharePrescriptionController { + val context = LocalContext.current + val prescriptionUseCase = rememberInstance() + return remember { + SharePrescriptionController( + context = context, + prescriptionUseCase = prescriptionUseCase + ) + } +} + +@Composable +fun rememberSharePrescriptionController( + profileId: ProfileIdentifier +): SharePrescriptionController { + val context = LocalContext.current + val prescriptionUseCase = rememberInstance() + return remember(profileId) { + SharePrescriptionController( + context = context, + prescriptionUseCase = prescriptionUseCase, + profileId = profileId + ) + } +} + +@Composable +fun SharePrescriptionHandler( + authenticationModeAndMethod: Flow +) { + val activeProfile = LocalProfileHandler.current.activeProfile + val controller = rememberSharePrescriptionController(activeProfile.id) + val intentHandler = LocalIntentHandler.current + val context = LocalContext.current + + val taskAdded = stringResource(R.string.share_import_prescription_added) + val taskExists = stringResource(R.string.share_import_prescription_exists) + val otherFailure = stringResource(R.string.share_import_error) + + LaunchedEffect(controller) { + // TODO: maybe handle all intents only after authentication + authenticationModeAndMethod.collectLatest { auth -> + if (auth is AuthenticationModeAndMethod.Authenticated) { + intentHandler.shareIntent.collect { intent -> + when (controller.handle(intent)) { + SharePrescriptionController.HandleResult.TaskAlreadyExists -> + createToastShort(context, taskExists) + + SharePrescriptionController.HandleResult.TaskSaved -> + createToastShort(context, taskAdded) + + SharePrescriptionController.HandleResult.Failure -> + createToastShort(context, otherFailure) + } + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt new file mode 100644 index 00000000..e0cf69e5 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/SyncedMedicationDetailScreen.kt @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +@file:Suppress("TooManyFunctions") + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.SnackbarHost +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.fhir.model.MedicationCategory +import de.gematik.ti.erp.app.medicationCategory +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.prescription.repository.codeToFormMapping +import de.gematik.ti.erp.app.prescription.repository.normSizeMapping +import de.gematik.ti.erp.app.substitutionAllowed +import de.gematik.ti.erp.app.supplyForm +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import de.gematik.ti.erp.app.utils.dateTimeMediumText +import de.gematik.ti.erp.app.utils.temporalText +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.TemporalAccessor + +@Composable +fun SyncedMedicationDetailScreen( + prescription: PrescriptionData.Synced, + medication: PrescriptionData.Medication, + onClickIngredient: (SyncedTaskData.Ingredient) -> Unit, + onBack: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Medication.Screen), + scaffoldState = scaffoldState, + listState = listState, + onBack = onBack, + topBarTitle = stringResource(R.string.synced_medication_detail_header), + navigationMode = NavigationBarMode.Back, + snackbarHost = { SnackbarHost(it, modifier = Modifier.navigationBarsPadding()) }, + actions = {} + ) { innerPadding -> + val med = when (medication) { + is PrescriptionData.Medication.Dispense -> medication.medicationDispense.medication + is PrescriptionData.Medication.Request -> medication.medicationRequest.medication + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .testTag(TestTag.Prescriptions.Details.Medication.Content), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + } + when (med) { + is SyncedTaskData.MedicationPZN -> pznMedicationInformation(med) + is SyncedTaskData.MedicationIngredient -> ingredientMedicationInformation(med, onClickIngredient) + is SyncedTaskData.MedicationCompounding -> compoundingMedicationInformation(med, onClickIngredient) + is SyncedTaskData.MedicationFreeText -> freeTextMedicationInformation(med) + null -> {} + } + + prescriptionInformation(prescription.authoredOn) + + when (medication) { + is PrescriptionData.Medication.Dispense -> + medicationDispense(medication.medicationDispense) + + is PrescriptionData.Medication.Request -> + medicationRequest(medication.medicationRequest) + } + item { + SpacerMedium() + } + } + } +} + +fun LazyListScope.prescriptionInformation(authoredOn: Instant) { + item { + AuthoredOnLabel(authoredOn) + } +} + +fun LazyListScope.medicationRequest(medicationRequest: SyncedTaskData.MedicationRequest) { + item { + DosageInstructionLabel(medicationRequest.dosageInstruction) + } + item { + medicationRequest.note?.let { NoteLabel(it) } + } + item { + medicationRequest.bvg?.let { BvgLabel(it) } + } + item { + SubstitutionLabel(medicationRequest.substitutionAllowed) + } +} + +fun LazyListScope.medicationDispense(medicationDispense: SyncedTaskData.MedicationDispense) { + item { + HandedOverLabel(medicationDispense.whenHandedOver) + } + item { + PerformerLabel(medicationDispense.performer) + } + item { + DosageInstructionLabel(medicationDispense.dosageInstruction) + } + item { + SubstitutionLabel(medicationDispense.wasSubstituted) + } +} + +fun LazyListScope.pznMedicationInformation(medication: SyncedTaskData.MedicationPZN) { + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Medication.Name), + text = medication.text, + label = stringResource(R.string.medication_trade_name) + ) + } + medication.amount?.let { + item { + AmountLabel(it) + } + } + medication.normSizeCode?.let { + item { + NormSizeLabel(it) + } + } + item { + PZNLabel(medication.uniqueIdentifier) + } + medication.form?.let { + item { + FormLabel(it) + } + } + + item { + CategoryLabel(medication.category) + } + item { + VaccineLabel(medication.vaccine) + } + medication.lotNumber?.let { + item { + LotNumberLabel(it) + } + } + medication.expirationDate?.let { + item { + ExpirationDateLabel(it) + } + } +} + +fun LazyListScope.ingredientMedicationInformation( + medication: SyncedTaskData.MedicationIngredient, + onClickIngredient: (SyncedTaskData.Ingredient) -> Unit +) { + medication.ingredients.forEachIndexed { index, ingredient -> + item { + IngredientNameLabel(ingredient.text) { + onClickIngredient(ingredient) + } + } + } + medication.normSizeCode?.let { + item { + NormSizeLabel(it) + } + } + + medication.form?.let { + item { + FormLabel(it) + } + } + medication.amount?.let { + item { + AmountLabel(it) + } + } + item { + CategoryLabel(medication.category) + } + item { + VaccineLabel(medication.vaccine) + } + medication.lotNumber?.let { + item { + LotNumberLabel(it) + } + } + medication.expirationDate?.let { + item { + ExpirationDateLabel(it) + } + } +} + +fun LazyListScope.compoundingMedicationInformation( + medication: SyncedTaskData.MedicationCompounding, + onClickIngredient: (SyncedTaskData.Ingredient) -> Unit +) { + item { + Label( + text = medication.text.takeIf { it.isNotEmpty() }, + label = stringResource(R.string.medication_compounding_name) + ) + } + medication.amount?.let { + item { + AmountLabel(it) + } + } + medication.packaging?.let { + item { + PackagingLabel(it) + } + } + medication.form?.let { + item { + FormLabel(it) + } + } + medication.manufacturingInstructions?.let { + item { + ManufacturingInstructionsLabel(it) + } + } + item { + CategoryLabel(medication.category) + } + item { + VaccineLabel(medication.vaccine) + } + medication.ingredients.forEachIndexed { index, ingredient -> + item { + IngredientNameLabel(ingredient.text, index + 1) { + onClickIngredient(ingredient) + } + } + } + medication.lotNumber?.let { + item { + LotNumberLabel(it) + } + } + medication.expirationDate?.let { + item { + ExpirationDateLabel(it) + } + } +} + +fun LazyListScope.freeTextMedicationInformation(medication: SyncedTaskData.MedicationFreeText) { + item { + Label(text = medication.text, label = stringResource(R.string.medication_freetext_name)) + } + item { + CategoryLabel(medication.category) + } + item { + VaccineLabel(medication.vaccine) + } + medication.form?.let { + item { + FormLabel(it) + } + } + medication.lotNumber?.let { + item { + LotNumberLabel(it) + } + } + medication.expirationDate?.let { + item { + ExpirationDateLabel(it) + } + } +} + +@Composable +fun NormSizeLabel(normSizeCode: String) { + val description = normSizeMapping[normSizeCode]?.let { resourceId -> + stringResource(resourceId) + } + Label( + text = "$normSizeCode${description?.let { " - $it" } ?: ""}", + label = stringResource(id = R.string.pres_detail_medication_label_normsize) + ) +} + +@Composable +fun PZNLabel(uniqueIdentifier: String) { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Medication.PZN), + text = uniqueIdentifier, + label = stringResource(id = R.string.pres_detail_medication_label_pzn) + ) +} + +@Composable +fun FormLabel(form: String) { + Label( + modifier = Modifier + .testTag(TestTag.Prescriptions.Details.Medication.SupplyForm) + .semantics { + supplyForm = form + }, + text = codeToFormMapping[form]?.let { resourceId -> + stringResource(resourceId) + } ?: form, + label = stringResource(id = R.string.pres_detail_medication_label_dosage_form) + ) +} + +@Composable +fun DosageInstructionLabel(dosageInstruction: String?) { + dosageInstruction?.let { instruction -> + val text = if (instruction.lowercase() == "dj") { + stringResource(R.string.pres_detail_medication_dj) + } else { + instruction + } + + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Medication.DosageInstruction), + text = text, + label = stringResource(R.string.pres_detail_medication_label_dosage_instruction) + ) + } +} + +@Composable +fun CategoryLabel(category: SyncedTaskData.MedicationCategory) { + val text = when (category) { + SyncedTaskData.MedicationCategory.ARZNEI_UND_VERBAND_MITTEL -> stringResource(R.string.medicines_bandages) + SyncedTaskData.MedicationCategory.BTM -> stringResource(R.string.narcotics) + SyncedTaskData.MedicationCategory.AMVV -> stringResource(R.string.amvv) + } + + Label( + modifier = Modifier + .testTag(TestTag.Prescriptions.Details.Medication.Category) + .then( + if (BuildKonfig.INTERNAL) { + Modifier.semantics { + medicationCategory = when (category) { + SyncedTaskData.MedicationCategory.ARZNEI_UND_VERBAND_MITTEL -> "00" + SyncedTaskData.MedicationCategory.BTM -> "01" + SyncedTaskData.MedicationCategory.AMVV -> "02" + } + } + } else { + Modifier + } + ), + text = text, + label = stringResource(id = R.string.pres_detail_medication_label_category) + ) +} + +@Composable +fun AmountLabel(amount: SyncedTaskData.Ratio) { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Medication.Amount), + text = amount.numerator?.value + " " + amount.numerator?.unit, + label = stringResource(id = R.string.pres_detail_medication_label_amount) + ) +} + +@Composable +fun BvgLabel(bvg: Boolean) { + val text = if (bvg) { + stringResource(id = R.string.pres_detail_yes) + } else { + stringResource(id = R.string.pres_detail_no) + } + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.Medication.BVG), + text = text, + label = stringResource(id = R.string.pres_detail_medication_label_bvg) + ) +} + +@Composable +fun AuthoredOnLabel(authoredOn: Instant) { + Label( + text = remember { dateTimeMediumText(authoredOn, ZoneId.systemDefault()) }, + label = stringResource(id = R.string.pres_detail_medication_label_authored_on) + ) +} + +@Composable +fun NoteLabel(note: String) { + Label( + text = note, + label = stringResource(id = R.string.pres_detail_medication_label_note) + ) +} + +@Composable +fun SubstitutionLabel(substitutionInfo: Boolean) { + val text = if (substitutionInfo) { + stringResource(id = R.string.pres_detail_yes) + } else { + stringResource(id = R.string.pres_detail_no) + } + Label( + modifier = Modifier + .testTag(TestTag.Prescriptions.Details.Medication.SubstitutionAllowed) + .semantics { + substitutionAllowed = substitutionInfo + }, + text = text, + label = stringResource(id = R.string.pres_detail_medication_label_subtitution) + ) +} + +@Composable +fun PackagingLabel(packaging: String) { + Label( + text = packaging, + label = stringResource(id = R.string.pres_detail_medication_label_packaging) + ) +} + +@Composable +fun ManufacturingInstructionsLabel(manufacturingInstructions: String) { + Label( + text = manufacturingInstructions, + label = stringResource(id = R.string.pres_detail_medication_label_manufacturing) + ) +} + +@Composable +fun PerformerLabel(performer: String) { + Label( + text = performer, + label = stringResource(id = R.string.pres_detail_medication_label_performer) + ) +} + +@Composable +fun HandedOverLabel(whenHandedOver: Instant) { + Label( + text = remember { dateTimeMediumText(whenHandedOver) }, + label = stringResource(id = R.string.pres_detail_medication_label_handed_over) + ) +} + +@Composable +fun ExpirationDateLabel(expirationDate: TemporalAccessor) { + Label( + text = remember { temporalText(expirationDate, ZoneId.systemDefault()) }, + label = stringResource(id = R.string.pres_detail_medication_label_expiration_date) + ) +} + +@Composable +fun VaccineLabel(isVaccine: Boolean) { + val text = if (isVaccine) { + stringResource(id = R.string.pres_detail_yes) + } else { + stringResource(id = R.string.pres_detail_no) + } + Label( + text = text, + label = stringResource(id = R.string.pres_detail_medication_vaccine) + ) +} + +@Composable +fun LotNumberLabel(lotNumber: String) { + Label( + text = lotNumber, + label = stringResource(id = R.string.pres_detail_medication_label_lot_number) + ) +} + +@Composable +fun IngredientAmountLabel(amount: String) { + Label( + text = amount, + label = stringResource(id = R.string.pres_detail_medication_label_ingredient_amount) + ) +} + +@Composable +fun IngredientNumberLabel(number: String) { + Label( + text = number, + label = stringResource(id = R.string.pres_detail_medication_label_ingredient_number) + ) +} + +@Composable +fun IngredientNameLabel(text: String, index: Int? = null, onClickLabel: (() -> Unit)? = null) { + Label( + text = text, + label = annotatedStringResource( + id = R.string.pres_detail_medication_label_ingredient_name, + index ?: "" + ).toString(), + onClick = onClickLabel + ) +} + +@Composable +fun StrengtLabel(strength: SyncedTaskData.Ratio) { + if (strength.numerator != null) { + Label( + text = strength.numerator.value + " " + strength.numerator.unit, + label = stringResource(id = R.string.pres_detail_medication_label_ingredient_strength_unit) + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/TechnicalInformation.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/TechnicalInformation.kt new file mode 100644 index 00000000..65c178dc --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/TechnicalInformation.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium + +@Composable +fun TechnicalInformation( + prescription: PrescriptionData.Prescription, + onBack: () -> Unit +) { + val listState = rememberLazyListState() + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.TechnicalInformation.Screen), + topBarTitle = stringResource(R.string.pres_detail_technical_information), + listState = listState, + onBack = onBack, + navigationMode = NavigationBarMode.Back + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .testTag(TestTag.Prescriptions.Details.TechnicalInformation.Content), + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + SpacerMedium() + } + prescription.accessCode?.let { + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.TechnicalInformation.AccessCode), + text = it, + label = stringResource(R.string.access_code) + ) + } + } + + item { + Label( + modifier = Modifier.testTag(TestTag.Prescriptions.Details.TechnicalInformation.TaskId), + text = prescription.taskId, + label = stringResource(R.string.task_id) + ) + SpacerMedium() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Mapper.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Mapper.kt deleted file mode 100644 index c9556d9b..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Mapper.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.detail.ui.model - -import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.repository.InsuranceCompanyDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationRequestDetail -import de.gematik.ti.erp.app.prescription.repository.OrganizationDetail -import de.gematik.ti.erp.app.prescription.repository.PatientDetail -import de.gematik.ti.erp.app.prescription.repository.PractitionerDetail -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode - -fun mapToUIPrescriptionDetailScanned( - task: Task, - matrix: BitMatrixCode?, - unRedeemMorePossible: Boolean -): UIPrescriptionDetailScanned { - return UIPrescriptionDetailScanned( - taskId = task.taskId, - redeemedOn = task.redeemedOn, - accessCode = task.accessCode, - number = requireNotNull(task.nrInScanSession), - scannedOn = requireNotNull(task.scannedOn), - bitmapMatrix = matrix, - unRedeemMorePossible = unRedeemMorePossible - ) -} - -fun mapToUIPrescriptionDetailSynced( - task: Task, - medication: MedicationDetail, - medicationRequest: MedicationRequestDetail, - medicationDispense: MedicationDispenseSimple?, - insurance: InsuranceCompanyDetail, - organization: OrganizationDetail, - patient: PatientDetail, - practitioner: PractitionerDetail, - matrix: BitMatrixCode? -): UIPrescriptionDetailSynced { - return UIPrescriptionDetailSynced( - taskId = task.taskId, - taskStatus = task.status, - redeemedOn = task.redeemedOn, - accessCode = task.accessCode, - redeemUntil = task.expiresOn, - acceptUntil = task.acceptUntil, - bitmapMatrix = matrix, - practitioner = practitioner, - organization = organization, - patient = patient, - insurance = insurance, - medication = medication, - medicationRequest = medicationRequest, - medicationDispense = medicationDispense - ) -} - -fun mapToUIPrescriptionOrder( - task: Task, - medication: MedicationDetail, - medicationRequest: MedicationRequestDetail, - patient: PatientDetail, -): UIPrescriptionOrder { - val uiPrescriptionOrder = UIPrescriptionOrder( - taskId = task.taskId, - accessCode = task.accessCode!!, - title = medication.text, - substitutionsAllowed = medicationRequest.substitutionAllowed, - ) - uiPrescriptionOrder.address = patient.address ?: "" - uiPrescriptionOrder.patientName = patient.name ?: "" - return uiPrescriptionOrder -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Navigation.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Navigation.kt index efe6eb50..ecfa51ee 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Navigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Navigation.kt @@ -18,8 +18,16 @@ package de.gematik.ti.erp.app.prescription.detail.ui.model -sealed class PrescriptionDetailsNavigationScreens( - val route: String -) { - object PrescriptionDetails : PrescriptionDetailsNavigationScreens("PrescriptionDetails") +import de.gematik.ti.erp.app.Route + +object PrescriptionDetailsNavigationScreens { + object Overview : Route("overview") + object MedicationOverview : Route("medicationOverview") + object Medication : Route("medication") + object Patient : Route("patient") + object Prescriber : Route("prescriber") + object Organization : Route("organization") + object Accident : Route("accident") + object TechnicalInformation : Route("technicalInformation") + object Ingredient : Route("ingredient") } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/PrescriptionData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/PrescriptionData.kt new file mode 100644 index 00000000..44ef832a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/PrescriptionData.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.detail.ui.model + +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier + +import java.time.Instant + +object PrescriptionData { + sealed interface Prescription { + val profileId: ProfileIdentifier + val taskId: String + val redeemedOn: Instant? + val accessCode: String? + } + + @Stable + class Scanned( + val task: ScannedTaskData.ScannedTask + ) : Prescription { + override val profileId: ProfileIdentifier = task.profileId + override val taskId: String = task.taskId + override val redeemedOn: Instant? = task.redeemedOn + override val accessCode: String = task.accessCode + val scannedOn: Instant = task.scannedOn + val isRedeemed = redeemedOn != null + } + + @Stable + class Synced( + val task: SyncedTaskData.SyncedTask + ) : Prescription { + override val profileId: ProfileIdentifier = task.profileId + override val taskId: String = task.taskId + override val redeemedOn: Instant? = task.redeemedOn() + override val accessCode: String? = task.accessCode + + val name = task.medicationName() + val state: SyncedTaskData.SyncedTask.TaskState = task.state() + val authoredOn: Instant = task.authoredOn + val expiresOn: Instant? = task.expiresOn + val acceptUntil: Instant? = task.acceptUntil + val patient: SyncedTaskData.Patient = task.patient + val practitioner: SyncedTaskData.Practitioner = task.practitioner + val insurance: SyncedTaskData.InsuranceInformation = task.insuranceInformation + val organization: SyncedTaskData.Organization = task.organization + val medicationRequest: SyncedTaskData.MedicationRequest = task.medicationRequest + val medicationDispenses: List = task.medicationDispenses + + val isDirectAssignment = task.isDirectAssignment() + val isSubstitutionAllowed = task.medicationRequest.substitutionAllowed + val isDeletable = task.isDeletable() + val isDispensed = task.medicationDispenses.isNotEmpty() + val isIncomplete = task.isIncomplete + + val failureToReport = task.failureToReport + } + + sealed interface Medication { + class Request(val medicationRequest: SyncedTaskData.MedicationRequest) : Medication + class Dispense(val medicationDispense: SyncedTaskData.MedicationDispense) : Medication + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetail.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetail.kt deleted file mode 100644 index 641495b6..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetail.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.detail.ui.model - -import androidx.compose.runtime.Immutable -import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple -import de.gematik.ti.erp.app.db.entities.TaskStatus -import de.gematik.ti.erp.app.prescription.repository.InsuranceCompanyDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationRequestDetail -import de.gematik.ti.erp.app.prescription.repository.OrganizationDetail -import de.gematik.ti.erp.app.prescription.repository.PatientDetail -import de.gematik.ti.erp.app.prescription.repository.PractitionerDetail -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter - -interface UIPrescriptionDetail { - val taskId: String - val redeemedOn: OffsetDateTime? - val accessCode: String? - val bitmapMatrix: BitMatrixCode? -} - -@Immutable -data class UIPrescriptionDetailScanned( - override val taskId: String, - override val redeemedOn: OffsetDateTime?, - override val accessCode: String?, - override val bitmapMatrix: BitMatrixCode?, - val number: Int, - val scannedOn: OffsetDateTime, - val unRedeemMorePossible: Boolean -) : UIPrescriptionDetail { - - fun formattedScannedInfo(text: String): String { - val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy - HH:mm:ss") - return scannedOn.format(formatter).replace("-", text) - } -} - -@Immutable -data class UIPrescriptionDetailSynced( - override val taskId: String, - override val redeemedOn: OffsetDateTime?, - override val accessCode: String?, - override val bitmapMatrix: BitMatrixCode?, - val redeemUntil: LocalDate?, - val acceptUntil: LocalDate?, - val patient: PatientDetail, - val practitioner: PractitionerDetail, - val medication: MedicationDetail, - val insurance: InsuranceCompanyDetail, - val organization: OrganizationDetail, - val medicationRequest: MedicationRequestDetail, - val medicationDispense: MedicationDispenseSimple?, - val taskStatus: TaskStatus? -) : UIPrescriptionDetail diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/model/ScannedTaskData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/model/ScannedTaskData.kt new file mode 100644 index 00000000..54cf24d1 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/model/ScannedTaskData.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.model + +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import java.time.Instant + +object ScannedTaskData { + data class ScannedTask( + val profileId: ProfileIdentifier, + val taskId: String, + val accessCode: String, + val scannedOn: Instant, + val redeemedOn: Instant? + ) { + fun isRedeemable() = redeemedOn == null + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt new file mode 100644 index 00000000..15c16cab --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.model + +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationProfileV1 +import java.time.Duration +import java.time.Instant +import java.time.temporal.TemporalAccessor + +val CommunicationWaitStateDelta: Duration = Duration.ofMinutes(10) + +// gemSpec_FD_eRp: A_21267 Prozessparameter - Berechtigungen für Nutzer +const val DIRECT_ASSIGNMENT_INDICATOR = "169" // direct assignment taskID starts with 169 + +object SyncedTaskData { + enum class TaskStatus { + Ready, InProgress, Completed, Other, Draft, Requested, Received, Accepted, Rejected, Canceled, OnHold, Failed; + } + + enum class CommunicationProfile { + ErxCommunicationDispReq, ErxCommunicationReply; + + fun toEntityValue() = when (this) { + CommunicationProfile.ErxCommunicationDispReq -> + CommunicationProfileV1.ErxCommunicationDispReq + + CommunicationProfile.ErxCommunicationReply -> + CommunicationProfileV1.ErxCommunicationReply + }.name + } + + data class SyncedTask( + val profileId: String, + val taskId: String, + val accessCode: String?, + val lastModified: Instant, + val organization: Organization, + val practitioner: Practitioner, + val patient: Patient, + val insuranceInformation: InsuranceInformation, + val expiresOn: Instant?, + val acceptUntil: Instant?, + val authoredOn: Instant, + val status: TaskStatus, + var isIncomplete: Boolean, + var pvsIdentifier: String, + var failureToReport: String, + val medicationRequest: MedicationRequest, + val medicationDispenses: List = emptyList(), + val communications: List = emptyList() + ) { + sealed interface TaskState + + data class Ready(val expiresOn: Instant, val acceptUntil: Instant) : TaskState + data class LaterRedeemable(val redeemableOn: Instant) : TaskState + + data class Pending(val sentOn: Instant, val toTelematikId: String) : TaskState + data class InProgress(val lastModified: Instant) : TaskState + data class Expired(val expiredOn: Instant) : TaskState + + data class Other(val state: TaskStatus, val lastModified: Instant) : TaskState + + fun state(now: Instant = Instant.now(), delta: Duration = CommunicationWaitStateDelta): TaskState = + when { + medicationRequest.multiplePrescriptionInfo.indicator && + medicationRequest.multiplePrescriptionInfo.start?.let { start -> + start > now + } == true -> { + LaterRedeemable(medicationRequest.multiplePrescriptionInfo.start) + } + + expiresOn != null && expiresOn < now && status != TaskStatus.Completed -> Expired(expiresOn) + status == TaskStatus.Ready && + accessCode != null && + communications.any { it.profile == CommunicationProfile.ErxCommunicationDispReq } && + redeemState(now, delta) == RedeemState.NotRedeemable -> { + val comm = this.communications + .filter { it.profile == CommunicationProfile.ErxCommunicationDispReq } + .maxBy { it.sentOn } + + Pending( + sentOn = comm.sentOn, + toTelematikId = comm.recipient + ) + } + + status == TaskStatus.Ready -> Ready( + expiresOn = requireNotNull(expiresOn), + acceptUntil = requireNotNull(acceptUntil) + ) + + status == TaskStatus.InProgress -> InProgress(lastModified = this.lastModified) + else -> Other(this.status, this.lastModified) + } + + enum class RedeemState { + NotRedeemable, + RedeemableAndValid, + RedeemableAfterDelta; + + fun isRedeemable() = this != NotRedeemable + } + + fun redeemedOn() = + if (status == TaskStatus.Completed) { + medicationDispenses.firstOrNull()?.whenHandedOver ?: lastModified + } else { + null + } + + /** + * The list of redeemable prescriptions. Should NOT be used as a filter for the active/archive tab! + * See [isActive] for a decision it this prescription should be shown in the "Active" or "Archive" tab. + */ + fun redeemState(now: Instant = Instant.now(), delta: Duration = CommunicationWaitStateDelta): RedeemState { + val expired = (expiresOn != null && expiresOn <= now) + val redeemableLater = medicationRequest.multiplePrescriptionInfo.indicator && + medicationRequest.multiplePrescriptionInfo.start?.let { + it > now + } == true + val ready = status == TaskStatus.Ready + val valid = accessCode != null + val latestDispenseReqCommunication = communications + .filter { it.profile == CommunicationProfile.ErxCommunicationDispReq } + .maxOfOrNull { it.sentOn } + val isDeltaLocked = latestDispenseReqCommunication?.let { (it + delta) > now } + + return when { + redeemableLater || expired -> RedeemState.NotRedeemable + ready && valid && latestDispenseReqCommunication == null -> RedeemState.RedeemableAndValid + ready && valid && isDeltaLocked == false -> RedeemState.RedeemableAfterDelta + ready && valid && isDeltaLocked == true -> RedeemState.NotRedeemable + else -> RedeemState.NotRedeemable + } + } + + fun isActive(now: Instant = Instant.now()): Boolean { + val notExpired = (expiresOn != null && now <= expiresOn) || expiresOn == null + val allowedStatus = status == TaskStatus.Ready || status == TaskStatus.InProgress + return notExpired && allowedStatus + } + + fun isDirectAssignment() = + taskId.startsWith(DIRECT_ASSIGNMENT_INDICATOR) + + fun isDeletable() = + when { + isDirectAssignment() -> status == TaskStatus.Completed + else -> true + } + + fun medicationRequestMedicationName() = + when (medicationRequest.medication) { + is MedicationPZN -> medicationRequest.medication.text + is MedicationCompounding -> medicationRequest.medication.text + is MedicationIngredient -> medicationRequest.medication.ingredients.firstOrNull()?.text ?: "" + is MedicationFreeText -> medicationRequest.medication.text + else -> "" + }.takeIf { it.isNotEmpty() } + + fun medicationName() = medicationRequestMedicationName() + fun organizationName() = organization.name ?: practitioner.name + } + + data class Address( + val line1: String, + val line2: String, + val postalCodeAndCity: String + ) { + fun joinToString(): String = + listOf( + this.line1, + this.line2, + this.postalCodeAndCity + ).filter { + it.isNotEmpty() + }.joinToString(", ") + } + + data class Organization( + val name: String? = null, + val address: Address? = null, + val uniqueIdentifier: String? = null, + val phone: String? = null, + val mail: String? = null + ) + + data class Practitioner( + val name: String?, + val qualification: String?, + val practitionerIdentifier: String? + ) + + data class Patient( + val name: String?, + val address: Address?, + val birthdate: Instant?, + val insuranceIdentifier: String? + ) + + data class InsuranceInformation( + val name: String? = null, + val status: String? = null + ) + + enum class AdditionalFee(val value: String?) { + None(null), + NotExempt("0"), + Exempt("1"), + ArtificialFertilization("2"); + + companion object { + fun valueOf(v: String?) = + values().find { + it.value == v + } ?: None + } + } + + data class MedicationRequest( + val medication: Medication? = null, + val dateOfAccident: Instant? = null, + val location: String? = null, + val emergencyFee: Boolean? = null, + val substitutionAllowed: Boolean, + val dosageInstruction: String? = null, + val multiplePrescriptionInfo: MultiplePrescriptionInfo, + val note: String?, + val bvg: Boolean? = null, + val additionalFee: AdditionalFee = AdditionalFee.valueOf(null) + ) + + data class MultiplePrescriptionInfo( + val indicator: Boolean = false, + val numbering: Ratio? = null, + val start: Instant? = null + ) + + data class MedicationDispense( + val dispenseId: String?, + val patientIdentifier: String, + val medication: Medication?, + val wasSubstituted: Boolean, + val dosageInstruction: String?, + val performer: String, + val whenHandedOver: Instant + ) + + enum class MedicationCategory { + ARZNEI_UND_VERBAND_MITTEL, + BTM, + AMVV; + } + + data class Quantity( + val value: String, + val unit: String + ) + + data class Ratio( + val numerator: Quantity?, + val denominator: Quantity? + ) + + data class Ingredient( + var text: String, + var form: String?, + var number: String?, + var amount: String?, + var strength: Ratio? + ) + + sealed interface Medication { + val category: MedicationCategory + val vaccine: Boolean + val text: String + val form: String? + val lotNumber: String? + val expirationDate: TemporalAccessor? + } + + data class MedicationFreeText( + override val category: MedicationCategory, + override val vaccine: Boolean, + override val text: String, + override val form: String?, + override val lotNumber: String?, + override val expirationDate: TemporalAccessor? + ) : Medication + + data class MedicationIngredient( + override val category: MedicationCategory, + override val vaccine: Boolean, + override val text: String, + override val form: String?, + override val lotNumber: String?, + override val expirationDate: TemporalAccessor?, + val normSizeCode: String?, + val amount: Ratio?, + val ingredients: List + + ) : Medication + + data class MedicationCompounding( + override val category: MedicationCategory, + override val vaccine: Boolean, + override val text: String, + override val form: String?, + override val lotNumber: String?, + override val expirationDate: TemporalAccessor?, + val manufacturingInstructions: String?, + val packaging: String?, + val amount: Ratio?, + val ingredients: List + + ) : Medication + + data class MedicationPZN( + override val category: MedicationCategory, + override val vaccine: Boolean, + override val text: String, + override val form: String?, + override val lotNumber: String?, + override val expirationDate: TemporalAccessor?, + val uniqueIdentifier: String, + val normSizeCode: String?, + val amount: Ratio? + ) : Medication + + data class Communication( + val taskId: String, + val communicationId: String, + val orderId: String, + val profile: CommunicationProfile, + val sentOn: Instant, + val sender: String, + val recipient: String, + val payload: String?, + val consumed: Boolean + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/KBVCodeMapping.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/KBVCodeMapping.kt index f1fcdfb2..f775e420 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/KBVCodeMapping.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/KBVCodeMapping.kt @@ -36,7 +36,8 @@ val normSizeMapping = mapOf( "Sonstiges" to R.string.kbv_norm_size_sonstiges ) -val codeToDosageFormMapping = mapOf( +val codeToFormMapping = mapOf( + "---" to R.string.kbv_code_dosage_form_nothing, "AEO" to R.string.kbv_code_dosage_form_aeo, "AMP" to R.string.kbv_code_dosage_form_amp, "APA" to R.string.kbv_code_dosage_form_apa, @@ -58,6 +59,7 @@ val codeToDosageFormMapping = mapOf( "BRE" to R.string.kbv_code_dosage_form_bre, "BTA" to R.string.kbv_code_dosage_form_bta, "CRE" to R.string.kbv_code_dosage_form_cre, + "DIG" to R.string.kbv_code_dosage_form_dig, "DFL" to R.string.kbv_code_dosage_form_dfl, "DIL" to R.string.kbv_code_dosage_form_dil, "DIS" to R.string.kbv_code_dosage_form_dis, diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt index 0cc7fe07..bf45749f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt @@ -18,138 +18,734 @@ package de.gematik.ti.erp.app.prescription.repository -import androidx.room.withTransaction -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.AuditEventSimple -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.db.entities.TaskWithMedicationDispense +import de.gematik.ti.erp.app.db.entities.deleteAll +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationProfileV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationCategoryV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationProfileV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.TaskStatusV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.db.tryWrite +import de.gematik.ti.erp.app.fhir.model.CommunicationProfile +import de.gematik.ti.erp.app.fhir.model.MedicationCategory +import de.gematik.ti.erp.app.fhir.model.MedicationProfile +import de.gematik.ti.erp.app.fhir.model.TaskStatus +import de.gematik.ti.erp.app.fhir.model.extractCommunications +import de.gematik.ti.erp.app.fhir.model.extractKBVBundle +import de.gematik.ti.erp.app.fhir.model.extractMedicationDispense +import de.gematik.ti.erp.app.fhir.model.extractTask +import de.gematik.ti.erp.app.fhir.model.extractTaskAndKBVBundle +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.realm.kotlin.Realm +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.ext.query +import io.realm.kotlin.ext.toRealmList +import kotlinx.coroutines.NonDisposableHandle.parent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonElement import java.time.Instant -import java.time.OffsetDateTime +import java.time.LocalDate import java.time.ZoneOffset -import javax.inject.Inject -class LocalDataSource @Inject constructor( - private val db: AppDatabase +class LocalDataSource( + private val realm: Realm ) { - suspend fun saveTasks(tasks: List) { - db.taskDao().insertMultipleTasks(*tasks.toTypedArray()) + suspend fun saveScannedTasks(profileId: ProfileIdentifier, tasks: List) { + realm.tryWrite { + queryFirst("id = $0", profileId)?.let { profile -> + tasks.forEach { task -> + if (query( + "syncedTasks.taskId = $0 OR scannedTasks.taskId = $0", + task.taskId + ).count().find() == 0L + ) { + profile.scannedTasks += copyToRealm( + ScannedTaskEntityV1().apply { + this.parent = profile + this.taskId = task.taskId + this.accessCode = task.accessCode + this.scannedOn = task.scannedOn.toRealmInstant() + this.redeemedOn = task.redeemedOn?.toRealmInstant() + } + ) + } + } + } + } } - suspend fun saveTask(task: EntityTask) { - db.taskDao().insertTask(task) - } + data class SaveTaskResult( + val isCompleted: Boolean, + val lastModified: Instant + ) + + private val mutex = Mutex() + + @Suppress("LongMethod", "ComplexMethod") + suspend fun saveTask(profileId: ProfileIdentifier, bundle: JsonElement): SaveTaskResult? = mutex.withLock { + return realm.tryWrite { + queryFirst("id = $0", profileId)?.let { profile -> + lateinit var taskEntity: SyncedTaskEntityV1 + + extractTaskAndKBVBundle( + bundle, + process = { taskResource, bundleResource -> + extractTask( + task = taskResource, + process = { taskId: String, accessCode: String?, lastModified: Instant, + expiresOn: LocalDate?, acceptUntil: LocalDate?, authoredOn: Instant, + status: TaskStatus -> + taskEntity = queryFirst("taskId = $0", taskId) ?: run { + copyToRealm(SyncedTaskEntityV1()).also { + profile.syncedTasks += it + } + } + + taskEntity.apply { + this.parent = profile + this.taskId = taskId + this.accessCode = accessCode + this.lastModified = lastModified.toRealmInstant() + this.status = when (status) { + TaskStatus.Ready -> TaskStatusV1.Ready + TaskStatus.InProgress -> TaskStatusV1.InProgress + TaskStatus.Completed -> TaskStatusV1.Completed + TaskStatus.Draft -> TaskStatusV1.Draft + TaskStatus.Requested -> TaskStatusV1.Requested + TaskStatus.Received -> TaskStatusV1.Received + TaskStatus.Accepted -> TaskStatusV1.Accepted + TaskStatus.Rejected -> TaskStatusV1.Rejected + TaskStatus.Canceled -> TaskStatusV1.Canceled + TaskStatus.OnHold -> TaskStatusV1.OnHold + TaskStatus.Failed -> TaskStatusV1.Failed + else -> TaskStatusV1.Other + } + this.expiresOn = + expiresOn?.atStartOfDay()?.toRealmInstant(ZoneOffset.UTC) + this.acceptUntil = acceptUntil?.atStartOfDay()?.toRealmInstant(ZoneOffset.UTC) + this.authoredOn = authoredOn.toRealmInstant() + } + } + ) - suspend fun saveCommunications(communications: List) { - db.withTransaction { - val tasks = db.taskDao().getAllTasksWithTaskIdOnly() - val communicationsWithTask = communications.filter { - it.taskId in tasks + try { + extractKBVBundle( + bundleResource, + processOrganization = { name, address, uniqueIdentifier, phone, mail -> + OrganizationEntityV1().apply { + this.name = name + this.address = address + this.uniqueIdentifier = uniqueIdentifier + this.phone = phone + this.mail = mail + } + }, + processPatient = { name, address, birthDate, insuranceIdentifier -> + PatientEntityV1().apply { + this.name = name + this.address = address + this.birthdate = birthDate?.atStartOfDay()?.toRealmInstant() + this.insuranceIdentifier = insuranceIdentifier + } + }, + processPractitioner = { name, qualification, practitionerIdentifier -> + PractitionerEntityV1().apply { + this.name = name + this.qualification = qualification + this.practitionerIdentifier = practitionerIdentifier + } + }, + processInsuranceInformation = { name, statusCode -> + InsuranceInformationEntityV1().apply { + this.name = name + this.statusCode = statusCode + } + }, + processAddress = { line, postalCode, city -> + AddressEntityV1().apply { + this.line1 = line?.getOrNull(0) ?: "" + this.line2 = line?.getOrNull(1) ?: "" + this.postalCodeAndCity = listOfNotNull(postalCode, city).joinToString(" ") + } + }, + processQuantity = { value, unit -> + QuantityEntityV1().apply { + this.value = value + this.unit = unit + } + }, + processRatio = { numerator, denominator -> + RatioEntityV1().apply { + this.numerator = numerator + this.denominator = denominator + } + }, + processIngredient = { text, form, number, amount, strength -> + IngredientEntityV1().apply { + this.text = text + this.form = form + this.number = number + this.amount = amount + this.strength = strength + } + }, + processMedication = { text, + medicationProfile, + medicationCategory, + form, + amount, + vaccine, + manufacturingInstructions, + packaging, + normSizeCode, + uniqueIdentifier, + ingredients, + _, + _ -> + MedicationEntityV1().apply { + this.text = text ?: "" + this.medicationProfile = when (medicationProfile) { + MedicationProfile.PZN -> MedicationProfileV1.PZN + MedicationProfile.COMPOUNDING -> MedicationProfileV1.COMPOUNDING + MedicationProfile.INGREDIENT -> MedicationProfileV1.INGREDIENT + MedicationProfile.FREETEXT -> MedicationProfileV1.FREETEXT + else -> error("empty medication profile") + } + this.medicationCategory = when (medicationCategory) { + MedicationCategory.ARZNEI_UND_VERBAND_MITTEL -> + MedicationCategoryV1.ARZNEI_UND_VERBAND_MITTEL + + MedicationCategory.BTM -> MedicationCategoryV1.BTM + MedicationCategory.AMVV -> MedicationCategoryV1.AMVV + else -> error("unknown medication category") + } + this.form = form + this.amount = amount + this.vaccine = vaccine + this.manufacturingInstructions = manufacturingInstructions + this.packaging = packaging + this.normSizeCode = normSizeCode + this.uniqueIdentifier = uniqueIdentifier + this.ingredients = ingredients.toRealmList() + } + }, + processMultiplePrescriptionInfo = { indicator, numbering, start -> + MultiplePrescriptionInfoEntityV1().apply { + this.indicator = indicator + this.numbering = numbering + this.start = start?.atStartOfDay()?.toRealmInstant() + } + }, + processMedicationRequest = { dateOfAccident, + location, + emergencyFee, + substitutionAllowed, + dosageInstruction, + multiplePrescriptionInfo, + note, + bvg, + additionalFee + -> + MedicationRequestEntityV1().apply { + this.dateOfAccident = dateOfAccident?.atStartOfDay()?.toRealmInstant() + this.location = location + this.emergencyFee = emergencyFee + this.substitutionAllowed = substitutionAllowed + this.dosageInstruction = dosageInstruction + this.multiplePrescriptionInfo = multiplePrescriptionInfo + this.note = note + this.bvg = bvg + this.additionalFee = additionalFee + } + }, + savePVSIdentifier = { pvsId: String? -> + taskEntity.apply { + this.pvsIdentifier = pvsId ?: "" + } + }, + save = { organization, + patient, + practitioner, + insuranceInformation, + medication, + medicationRequest -> + taskEntity.apply { + this.organization = organization + this.patient = patient + this.practitioner = practitioner + this.insuranceInformation = insuranceInformation + this.medicationRequest = medicationRequest.apply { + this.medication = medication + } + } + } + ) + } catch (expected: Exception) { + taskEntity.apply { + this.isIncomplete = true + this.failureToReport = expected.message ?: "" + } + } + } + ) + + // delete scanned task + queryFirst("taskId = $0", taskEntity.taskId)?.let { delete(it) } + + SaveTaskResult( + isCompleted = taskEntity.status == TaskStatusV1.Completed, + lastModified = taskEntity.lastModified.toInstant() + ) } - db.communicationsDao() - .insertMultipleCommunications(*communicationsWithTask.toTypedArray()) } } - suspend fun saveAuditEvents(auditEvents: List) { - db.taskDao().insertAuditEvents(*auditEvents.toTypedArray()) - } + fun loadSyncedTasks(profileId: ProfileIdentifier): Flow> = + realm.query("parent.id = $0", profileId) + .asFlow() + .map { syncedTasks -> + syncedTasks.list.map { syncedTask -> + syncedTask.toSyncedTask() + } + } - suspend fun saveMedicationDispense(medicationDispense: MedicationDispenseSimple) { - db.taskDao().insertMedicationDispenses(medicationDispense) - } + fun loadSyncedTaskByTaskId(taskId: String): Flow = + realm.query("taskId = $0", taskId) + .first() + .asFlow() + .map { syncedTask -> + syncedTask.obj?.toSyncedTask() + } - suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) { - db.taskDao().insertLowDetailEvent(lowDetailEvent) - } + fun loadScannedTaskByTaskId(taskId: String): Flow = + realm.query("taskId = $0", taskId) + .first() + .asFlow() + .map { scannedTask -> + scannedTask.obj?.toScannedTask() + } - fun loadLowDetailEvents(taskId: String): Flow> = - db.taskDao().getLowDetailEvents(taskId) + suspend fun saveCommunications(communications: JsonElement): Int = + realm.tryWrite { + val totalCommunicationsInBundle = + extractCommunications(communications) { + taskId, communicationId, orderId, profile, sentOn, sender, recipient, payload -> - fun deleteLowDetailEvents(taskId: String) { - db.taskDao().deleteLowDetailEvents(taskId) - } + val entity = CommunicationEntityV1().apply { + this.profile = when (profile) { + CommunicationProfile.ErxCommunicationDispReq -> + CommunicationProfileV1.ErxCommunicationDispReq - fun loadTasks(profileName: String): Flow> { - return db.taskDao().getAllTasks(profileName) - } + CommunicationProfile.ErxCommunicationReply -> + CommunicationProfileV1.ErxCommunicationReply + } + this.taskId = taskId + this.communicationId = communicationId + this.orderId = orderId ?: "" + this.sentOn = sentOn.toRealmInstant() + this.sender = sender + this.recipient = recipient + this.payload = payload + this.consumed = false + } - fun loadScannedTasksWithoutBundle(profileName: String): Flow> = - db.taskDao().getScannedTasksWithoutBundle(profileName) + queryFirst("taskId = $0", taskId)?.let { syncedTask -> + entity.parent = syncedTask + syncedTask.communications += copyToRealm(entity) + } + } - fun loadSyncedTasksWithoutBundle(profileName: String): Flow> = - db.taskDao().getSyncedTasksWithoutBundle(profileName) + totalCommunicationsInBundle + } - fun loadTaskWithMedicationDispenseForTaskId(taskId: String): Flow { - return db.taskDao().getTaskWithMedicationDispenseForTaskId(taskId) - } + suspend fun saveMedicationDispense(taskId: String, bundle: JsonElement) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.let { syncedTask -> + extractMedicationDispense( + bundle, + quantityFn = { value, unit -> + QuantityEntityV1().apply { + this.value = value + this.unit = unit + } + }, + ratioFn = { numerator, denominator -> + RatioEntityV1().apply { + this.numerator = numerator + this.denominator = denominator + } + }, + ingredientFn = { text, form, number, amount, strength -> + IngredientEntityV1().apply { + this.text = text + this.form = form + this.number = number + this.amount = amount + this.strength = strength + } + }, + processMedication = { text, + medicationProfile, + medicationCategory, + form, + amount, + vaccine, + manufacturingInstructions, + packaging, + normSizeCode, + uniqueIdentifier, + ingredients, + lotNumber, + expirationDate -> + MedicationEntityV1().apply { + this.text = text ?: "" + this.medicationProfile = when (medicationProfile) { + MedicationProfile.PZN -> MedicationProfileV1.PZN + MedicationProfile.COMPOUNDING -> MedicationProfileV1.COMPOUNDING + MedicationProfile.INGREDIENT -> MedicationProfileV1.INGREDIENT + MedicationProfile.FREETEXT -> MedicationProfileV1.FREETEXT + else -> error("empty medication profile") + } + this.medicationCategory = when (medicationCategory) { + MedicationCategory.ARZNEI_UND_VERBAND_MITTEL -> + MedicationCategoryV1.ARZNEI_UND_VERBAND_MITTEL - fun loadTasksForTaskId(vararg taskIds: String): Flow> { - return db.taskDao().getTasksForTaskId(*taskIds) - } + MedicationCategory.BTM -> MedicationCategoryV1.BTM + MedicationCategory.AMVV -> MedicationCategoryV1.AMVV + else -> error("unknown medication category") + } + this.form = form + this.amount = amount + this.vaccine = vaccine + this.manufacturingInstructions = manufacturingInstructions + this.packaging = packaging + this.normSizeCode = normSizeCode + this.uniqueIdentifier = uniqueIdentifier + this.ingredients = ingredients.toRealmList() + this.lotNumber = lotNumber + this.expirationDate = expirationDate + } + }, + processMedicationDispense = { dispenseId, patientIdentifier, medication, + wasSubstituted, dosageInstruction, performer, whenHandedOver -> - suspend fun deleteTaskByTaskId(taskId: String) { - db.taskDao().deleteTaskByTaskId(taskId) + if (query("dispenseId = $0", dispenseId) + .count() + .find() == 0L + ) { + syncedTask.medicationDispenses += MedicationDispenseEntityV1().apply { + this.dispenseId = dispenseId + this.patientIdentifier = patientIdentifier + this.medication = medication + this.wasSubstituted = wasSubstituted + this.dosageInstruction = dosageInstruction + this.performer = performer + this.whenHandedOver = whenHandedOver.atStartOfDay().toRealmInstant() + } + } + } + ) + } + } } - suspend fun updateRedeemedOnForAllTasks(taskIds: List, tm: OffsetDateTime?) { - db.taskDao().updateRedeemedOnForAllTasks(taskIds, tm) - } + fun loadScannedTasks(profileId: ProfileIdentifier): Flow> = + realm.query("parent.id = $0", profileId) + .asFlow() + .map { scannedTasks -> + scannedTasks.list.map { task -> + task.toScannedTask() + } + } - suspend fun updateRedeemedOnForSingleTask(taskId: String, tm: OffsetDateTime?) { - db.taskDao().updateRedeemedOnForSingleTask(taskId, tm) + suspend fun deleteTask(taskId: String) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.let { delete(it) } + queryFirst("taskId = $0", taskId)?.let { + deleteAll(it) + } + } } - fun loadTasksForRedeemedOn(redeemedOn: OffsetDateTime, profileName: String): Flow> { - return db.taskDao().loadTasksForRedeemedOn(redeemedOn, profileName) + suspend fun updateRedeemedOn(taskId: String, timestamp: Instant?) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.apply { + this.redeemedOn = timestamp?.toRealmInstant() + } + } } - suspend fun getAllTasksWithTaskIdOnly(profileName: String): List { - return db.taskDao().getAllTasksWithTaskIdOnly(profileName) - } + fun loadTaskIds(): Flow> = + combine( + realm.query().asFlow(), + realm.query().asFlow() + ) { syncedTasks, scannedTasks -> + syncedTasks.list.map { it.taskId } + scannedTasks.list.map { it.taskId } + } - fun updateScanSessionName(name: String?, scanSessionEnd: OffsetDateTime) { - db.taskDao().updateScanSessionName(name, scanSessionEnd) + suspend fun updateTaskSyncedUpTo(profileId: ProfileIdentifier, timestamp: Instant) { + realm.tryWrite { + queryFirst("id = $0", profileId)?.apply { + this.lastTaskSynced = timestamp.toRealmInstant() + } + } } - fun loadCommunications( - profile: CommunicationProfile, - userProfile: String - ): Flow> { - return db.communicationsDao().getAllCommunications(profile, userProfile) - } + fun taskSyncedUpTo(profileId: ProfileIdentifier): Flow = + realm.query("id = $0", profileId) + .first() + .asFlow() + .map { profile -> + profile.obj?.lastTaskSynced?.toInstant() + } +} - fun loadUnreadCommunications( - profile: CommunicationProfile, - userProfile: String - ): Flow> { - return db.communicationsDao() - .getAllUnreadCommunications(profile = profile, userProfile = userProfile) - } +fun SyncedTaskEntityV1.toSyncedTask(): SyncedTaskData.SyncedTask = + SyncedTaskData.SyncedTask( + profileId = this.parent!!.id, + isIncomplete = this.isIncomplete, + pvsIdentifier = this.pvsIdentifier, + failureToReport = this.failureToReport, + taskId = this.taskId, + accessCode = this.accessCode, + lastModified = this.lastModified.toInstant(), + organization = SyncedTaskData.Organization( + name = this.organization?.name, + address = this.organization?.address?.let { + SyncedTaskData.Address( + line1 = it.line1, + line2 = it.line2, + postalCodeAndCity = it.postalCodeAndCity + ) + }, + uniqueIdentifier = this.organization?.uniqueIdentifier, + phone = this.organization?.phone, + mail = this.organization?.mail + ), + practitioner = SyncedTaskData.Practitioner( + name = this.practitioner?.name, + qualification = this.practitioner?.qualification, + practitionerIdentifier = this.practitioner?.practitionerIdentifier + ), + patient = SyncedTaskData.Patient( + name = this.patient?.name, + address = this.patient?.address?.let { + SyncedTaskData.Address( + line1 = it.line1, + line2 = it.line2, + postalCodeAndCity = it.postalCodeAndCity + ) + }, + birthdate = this.patient?.birthdate?.toInstant(), + insuranceIdentifier = this.patient?.insuranceIdentifier + ), + insuranceInformation = SyncedTaskData.InsuranceInformation( + name = this.insuranceInformation?.name, + status = this.insuranceInformation?.statusCode + ), + expiresOn = this.expiresOn?.toInstant(), + acceptUntil = this.acceptUntil?.toInstant(), + authoredOn = this.authoredOn.toInstant(), + status = when (this.status) { + TaskStatusV1.Ready -> SyncedTaskData.TaskStatus.Ready + TaskStatusV1.InProgress -> SyncedTaskData.TaskStatus.InProgress + TaskStatusV1.Completed -> SyncedTaskData.TaskStatus.Completed + TaskStatusV1.Other -> SyncedTaskData.TaskStatus.Other + TaskStatusV1.Draft -> SyncedTaskData.TaskStatus.Draft + TaskStatusV1.Requested -> SyncedTaskData.TaskStatus.Requested + TaskStatusV1.Received -> SyncedTaskData.TaskStatus.Received + TaskStatusV1.Accepted -> SyncedTaskData.TaskStatus.Accepted + TaskStatusV1.Rejected -> SyncedTaskData.TaskStatus.Rejected + TaskStatusV1.Canceled -> SyncedTaskData.TaskStatus.Canceled + TaskStatusV1.OnHold -> SyncedTaskData.TaskStatus.OnHold + TaskStatusV1.Failed -> SyncedTaskData.TaskStatus.Failed + }, + medicationRequest = SyncedTaskData.MedicationRequest( + medication = this.medicationRequest?.medication?.toMedication(), + dateOfAccident = this.medicationRequest?.dateOfAccident?.toInstant(), + location = this.medicationRequest?.location, + emergencyFee = this.medicationRequest?.emergencyFee, + substitutionAllowed = this.medicationRequest?.substitutionAllowed ?: false, + dosageInstruction = this.medicationRequest?.dosageInstruction, + multiplePrescriptionInfo = SyncedTaskData.MultiplePrescriptionInfo( + indicator = this.medicationRequest?.multiplePrescriptionInfo?.indicator ?: false, + numbering = SyncedTaskData.Ratio( + numerator = SyncedTaskData.Quantity( + value = this.medicationRequest?.multiplePrescriptionInfo?.numbering?.numerator?.value ?: "", + unit = "" + ), + denominator = SyncedTaskData.Quantity( + value = this.medicationRequest?.multiplePrescriptionInfo?.numbering?.denominator?.value ?: "", + unit = "" + ) + ), + start = this.medicationRequest?.multiplePrescriptionInfo?.start?.toInstant() + ), + additionalFee = when (this.medicationRequest?.additionalFee) { + "0" -> SyncedTaskData.AdditionalFee.NotExempt + "1" -> SyncedTaskData.AdditionalFee.Exempt + "2" -> SyncedTaskData.AdditionalFee.ArtificialFertilization + else -> SyncedTaskData.AdditionalFee.None + }, + note = this.medicationRequest?.note, + bvg = this.medicationRequest?.bvg + ), + medicationDispenses = this.medicationDispenses.map { medicationDispense -> + SyncedTaskData.MedicationDispense( + dispenseId = medicationDispense.dispenseId, + patientIdentifier = medicationDispense.patientIdentifier, + medication = medicationDispense.medication.toMedication(), + wasSubstituted = medicationDispense.wasSubstituted, + dosageInstruction = medicationDispense.dosageInstruction, + performer = medicationDispense.performer, + whenHandedOver = medicationDispense.whenHandedOver.toInstant() + ) + }, + communications = this.communications.mapNotNull { communication -> + communication.toCommunication() + } + ) - suspend fun setCommunicationsAcknowledgedStatus(communicationId: String, consumed: Boolean) { - db.communicationsDao().updateCommunication(communicationId, consumed) - } +private fun MedicationEntityV1?.toMedication(): SyncedTaskData.Medication? = + when (this?.medicationProfile) { + MedicationProfileV1.PZN -> SyncedTaskData.MedicationPZN( + uniqueIdentifier = this.uniqueIdentifier ?: "", + category = this.medicationCategory.toMedicationCategory(), + vaccine = this.vaccine, + text = this.text, + form = this.form, + lotNumber = this.lotNumber, + expirationDate = this.expirationDate, + normSizeCode = this.normSizeCode, + amount = this.amount.toRatio() + ) + + MedicationProfileV1.COMPOUNDING -> SyncedTaskData.MedicationCompounding( + category = this.medicationCategory.toMedicationCategory(), + vaccine = this.vaccine, + text = this.text, + form = this.form, + lotNumber = this.lotNumber, + expirationDate = this.expirationDate, + manufacturingInstructions = this.manufacturingInstructions, + packaging = this.packaging, + amount = this.amount.toRatio(), + ingredients = this.ingredients.toIngredients() + ) - suspend fun updateTaskSyncedUpTo(profileName: String, timestamp: Instant?) { - db.profileDao().updateLastTaskSynced(profileName, timestamp) + MedicationProfileV1.INGREDIENT -> SyncedTaskData.MedicationIngredient( + category = this.medicationCategory.toMedicationCategory(), + vaccine = this.vaccine, + text = this.text, + form = this.form, + lotNumber = this.lotNumber, + expirationDate = this.expirationDate, + normSizeCode = this.normSizeCode, + amount = this.amount.toRatio(), + ingredients = this.ingredients.toIngredients() + ) + + MedicationProfileV1.FREETEXT -> SyncedTaskData.MedicationFreeText( + category = this.medicationCategory.toMedicationCategory(), + vaccine = this.vaccine, + text = this.text, + form = this.form, + lotNumber = this.lotNumber, + expirationDate = this.expirationDate + ) + + else -> null } - suspend fun taskSyncedUpTo(profileName: String): Instant? { - return db.profileDao().getLastTaskSynced(profileName) +private fun RatioEntityV1?.toRatio(): SyncedTaskData.Ratio? = this?.let { + SyncedTaskData.Ratio( + numerator = it.numerator?.let { quantity -> + SyncedTaskData.Quantity( + value = quantity.value, + unit = quantity.unit + ) + }, + denominator = it.denominator?.let { quantity -> + SyncedTaskData.Quantity( + value = quantity.value, + unit = quantity.unit + ) + } + ) +} + +private fun RealmList.toIngredients(): List = + this.map { + SyncedTaskData.Ingredient( + text = it.text, + form = it.form, + number = it.number, + amount = it.amount, + strength = it.strength.toRatio() + ) } - suspend fun setAllAuditEventsSyncedUpTo(profileName: String) { - val timestamp = db.taskDao().getLatestAuditEventTimeStamp() - db.profileDao().updateAuditEventSynced(timestamp, profileName) +private fun MedicationCategoryV1?.toMedicationCategory(): SyncedTaskData.MedicationCategory = + when (this) { + MedicationCategoryV1.ARZNEI_UND_VERBAND_MITTEL -> SyncedTaskData.MedicationCategory.ARZNEI_UND_VERBAND_MITTEL + MedicationCategoryV1.BTM -> SyncedTaskData.MedicationCategory.BTM + MedicationCategoryV1.AMVV -> SyncedTaskData.MedicationCategory.AMVV + else -> error("unknown medication category") } - suspend fun auditEventsSyncedUpTo(profileName: String): OffsetDateTime { - return db.profileDao().getLastAuditEventSynced(profileName) - ?: Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC) +fun CommunicationEntityV1.toCommunication() = + if (this.profile == CommunicationProfileV1.Unknown) { + null + } else { + SyncedTaskData.Communication( + taskId = this.taskId, + communicationId = this.communicationId, + orderId = this.orderId, + profile = when (this.profile) { + CommunicationProfileV1.ErxCommunicationDispReq -> + SyncedTaskData.CommunicationProfile.ErxCommunicationDispReq + + CommunicationProfileV1.ErxCommunicationReply -> + SyncedTaskData.CommunicationProfile.ErxCommunicationReply + + else -> error("should not happen") + }, + sentOn = this.sentOn.toInstant(), + sender = this.sender, + recipient = this.recipient, + payload = this.payload, + consumed = this.consumed + ) } -} + +fun ScannedTaskEntityV1.toScannedTask() = + ScannedTaskData.ScannedTask( + profileId = this.parent!!.id, + taskId = this.taskId, + accessCode = this.accessCode, + scannedOn = this.scannedOn.toInstant(), + redeemedOn = this.redeemedOn?.toInstant() + ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/Mapper.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/Mapper.kt deleted file mode 100644 index c4b76528..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/Mapper.kt +++ /dev/null @@ -1,449 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.repository - -import androidx.annotation.StringRes -import ca.uhn.fhir.parser.IParser -import de.gematik.ti.erp.app.db.entities.AuditEventSimple -import de.gematik.ti.erp.app.db.entities.COMMUNICATION_TYPE_DISP_REQ -import de.gematik.ti.erp.app.db.entities.COMMUNICATION_TYPE_REPLY -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple -import de.gematik.ti.erp.app.db.entities.TaskStatus -import de.gematik.ti.erp.app.utils.convertFhirDateToLocalDate -import de.gematik.ti.erp.app.utils.convertFhirDateToOffsetDateTime -import java.time.LocalDate -import javax.inject.Inject -import org.hl7.fhir.r4.model.Address -import org.hl7.fhir.r4.model.AuditEvent -import org.hl7.fhir.r4.model.BooleanType -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.CodeType -import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.ContactPoint -import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.DomainResource -import org.hl7.fhir.r4.model.HumanName -import org.hl7.fhir.r4.model.MedicationDispense -import org.hl7.fhir.r4.model.MedicationRequest -import org.hl7.fhir.r4.model.Reference -import org.hl7.fhir.r4.model.StringType -import org.hl7.fhir.r4.model.Task - -typealias EntityTask = de.gematik.ti.erp.app.db.entities.Task -typealias FhirPractitioner = org.hl7.fhir.r4.model.Practitioner -typealias FhirMedication = org.hl7.fhir.r4.model.Medication -typealias FhirMedicationRequest = MedicationRequest -typealias FhirPatient = org.hl7.fhir.r4.model.Patient -typealias FhirOrganization = org.hl7.fhir.r4.model.Organization -typealias FhirCoverage = org.hl7.fhir.r4.model.Coverage - -inline fun Bundle.extractResources(): List? { - return entry?.let { it -> - it.filter { it.resource is T } - .map { it.resource as T } - } -} - -inline fun Bundle.extractSingleResource(): T? { - return entry?.firstOrNull()?.let { it as T } -} - -inline fun Bundle.BundleEntryComponent.extractResource(): T? { - return entries().let { it -> - it.filter { it.resource is T } - .map { it.resource as T } - .firstOrNull() - } -} - -inline fun Bundle.BundleEntryComponent.extractResourceForReference( - reference: String -): T? { - return entries().let { it -> - it.filter { - it.resource is T && it.resource.id == reference - }.map { - it.resource as T - } - .firstOrNull() - } -} - -@Suppress("UNCHECKED_CAST") -fun Bundle.BundleEntryComponent.entries(): List { - return resource.getChildByName("entry").values as List -} - -fun Task.extractKBVBundleReference(): String? { - return ( - input.find { - val code = (it as Task.ParameterComponent).type.coding[0].code - val system = it.type.coding[0].system - - code == "2" && system == "https://gematik.de/fhir/CodeSystem/Documenttype" - }?.value as Reference - ).reference -} - -fun MedicationRequest.findReferences() = mapOf( - "medication" to medicationReference.reference, - "patient" to subject.reference, - "practitioner" to requester.reference, - "insuranceReference" to insurance[0].reference -) - -// extracts the very first dosage instruction -fun MedicationRequest.extractDosageInstructions(): String? { - if (this.hasDosageInstruction() && ( - this.dosageInstruction?.first() - ?.getExtensionByUrl("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_DosageFlag")?.value as? BooleanType? - )?.value == true - ) { - return this.dosageInstruction?.first()?.text ?: "" - } - return null -} - -fun Bundle.extractKBVBundle(reference: String): Bundle.BundleEntryComponent? { - val cleanRefId = - if (reference.first() == '#') { - reference.subSequence(1, reference.length) - } else { - reference - } - - // BUG: Workaround for https://github.com/hapifhir/org.hl7.fhir.core/pull/12 - return entry.find { it.resource.id.removePrefix("urn:uuid:") == cleanRefId } -} - -fun Task.accessCode(): String? { - identifier.forEach { - if (it.hasSystem()) { - if (it.system == "https://gematik.de/fhir/NamingSystem/AccessCode") { - return it.value - } - } - } - return null -} - -fun Task.prescriptionId(): String? { - identifier.forEach { - if (it.hasSystem()) { - if (it.system == "https://gematik.de/fhir/NamingSystem/PrescriptionID") { - return it.value - } - } - } - return null -} -// -// private fun Task.prescriptionFlowTypeCode(): Int { -// return (extension[0].value as Coding).code.toInt() -// } - -class Mapper @Inject constructor( - private val fhirParser: IParser -) { - - private fun extractDateExtension(task: FhirTask, extensionUrl: String): LocalDate? { - - val fhirDate = - task.getExtensionByUrl(extensionUrl) - ?.value as DateType? - - return LocalDate.parse(fhirDate?.valueAsString) - } - - fun parseTaskIds(bundle: Bundle): List { - return bundle.extractResources()?.map { - it.idElement.idPart - } ?: listOf() - } - - /** - * Maps a list of Fhir Communications to Communication entities. - */ - fun mapFhirBundleToCommunications(bundle: Bundle, profileName: String): List { - return bundle.extractResources()?.mapNotNull { fhirCommunication -> - when (fhirCommunication.meta.profile[0].value.split("|").first()) { - COMMUNICATION_TYPE_DISP_REQ -> mapToDispReqCommunication( - fhirCommunication, profileName - ) - COMMUNICATION_TYPE_REPLY -> mapToCommunicationReply( - fhirCommunication, profileName - ) - else -> null // we can't handle other profiles currently - } - } ?: error("No communication found!") - } - - private fun extractTaskIdFromReference(reference: String): String { - return reference.split("/")[1] - } - - private fun mapToDispReqCommunication( - fhirCommunication: FhirCommunication, - profileName: String - ) = Communication( - communicationId = fhirCommunication.idElement.idPart, - profile = CommunicationProfile.ErxCommunicationDispReq, - taskId = extractTaskIdFromReference(fhirCommunication.basedOn[0].reference), - time = fhirCommunication.sent.toString(), - telematicsId = fhirCommunication.recipient[0].identifier.value, - kbvUserId = fhirCommunication.sender.identifier.value, - payload = fhirCommunication.payload[0].content.toString(), - profileName = profileName - ) - - private fun mapToCommunicationReply( - fhirCommunication: FhirCommunication, - profileName: String - ) = Communication( - communicationId = fhirCommunication.idElement.idPart, - profile = CommunicationProfile.ErxCommunicationReply, - taskId = extractTaskIdFromReference(fhirCommunication.basedOn[0].reference), - time = fhirCommunication.sent.toString(), - kbvUserId = fhirCommunication.recipient[0].identifier.value, - telematicsId = fhirCommunication.sender.identifier.value, - payload = fhirCommunication.payload[0].content.toString(), - profileName = profileName - ) - - /** - * Maps Task and KBV Bundle together to a complete Task Entity - * - * @param bundle the bundle consists of a Task which is referencing an included KBV Bundle. - */ - fun mapFhirBundleToTaskWithKBVBundle(bundle: Bundle, profileName: String): EntityTask = - bundle.extractResources()?.firstOrNull()?.let { fhirTask -> - fhirTask.extractKBVBundleReference()?.let { kbvBundleReference -> - val kbvBundle = requireNotNull(bundle.extractKBVBundle(kbvBundleReference)) - - var _fhirMedication: FhirMedication? = null - var _fhirMedicationRequest: FhirMedicationRequest? = null - var _fhirOrganization: FhirOrganization? = null - var _fhirPractitioner: FhirPractitioner? = null - - kbvBundle.entries().map { - when (val resource = it.resource) { - is FhirMedication -> _fhirMedication = resource - is FhirMedicationRequest -> _fhirMedicationRequest = resource - is FhirOrganization -> _fhirOrganization = resource - is FhirPractitioner -> _fhirPractitioner = resource - } - } - - val fhirMedication = requireNotNull(_fhirMedication) - val fhirMedicationRequest = requireNotNull(_fhirMedicationRequest) - val fhirOrganization = requireNotNull(_fhirOrganization) - val fhirPractitioner = requireNotNull(_fhirPractitioner) - - val kbvBundleRawString = - fhirParser.setPrettyPrint(false).encodeResourceToString(kbvBundle.resource) - - EntityTask( - taskId = fhirTask.idElement.idPart, - profileName = profileName, - accessCode = fhirTask.accessCode(), - lastModified = fhirTask.lastModified.convertFhirDateToOffsetDateTime(), - organization = fhirOrganization.name - ?: fhirPractitioner.nameFirstRep.nameAsSingleString, - medicationText = fhirMedication.code.text, - expiresOn = extractDateExtension( - fhirTask, - "https://gematik.de/fhir/StructureDefinition/ExpiryDate" - ), - acceptUntil = extractDateExtension( - fhirTask, - "https://gematik.de/fhir/StructureDefinition/AcceptDate" - ), - authoredOn = fhirMedicationRequest.authoredOn?.convertFhirDateToOffsetDateTime(), - status = TaskStatus.fromFhirTask(fhirTask.status), - scannedOn = null, - scanSessionEnd = null, - nrInScanSession = null, - rawKBVBundle = kbvBundleRawString.toByteArray() - ) - } ?: error("KBV Bundle not found!") - } ?: error("No task found!") - - /** - * Throws an exception if the bundle couldn't be parsed. - */ - fun parseKBVBundle(rawKBVBundle: ByteArray): Bundle { - return fhirParser.parseResource(rawKBVBundle.decodeToString()) as Bundle - } - - fun mapFhirBundleToAuditEvents(profileName: String, fhirBundle: Bundle): List { - return fhirBundle.extractResources()?.map { - AuditEventSimple( - id = it.idElement.idPart, - locale = if (it.language.isNullOrEmpty()) "de" else it.language, - text = it.text.div.allText(), - timestamp = it.recorded.convertFhirDateToOffsetDateTime(), - taskId = it.entity[0].what.referenceElement.idPart, - profileName = profileName - ) - } ?: error("No AuditEvents found in given Bundle $fhirBundle") - } - - fun mapMedicationDispenseToMedicationDispenseSimple(medicationDispense: MedicationDispense): MedicationDispenseSimple { - val medication = medicationDispense.contained[0] as FhirMedication - - return MedicationDispenseSimple( - taskId = medicationDispense.identifier[0].value, - patientIdentifier = medicationDispense.subject.identifier.value, - // PZN could be optional in future - uniqueIdentifier = medication.code?.coding?.find { it.system == "http://fhir.de/CodeSystem/ifa/pzn" }?.code - ?: "", - wasSubstituted = medicationDispense.substitution.wasSubstituted, - text = medication.code?.text, - type = medication.form?.coding?.find { it.system == "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM" }?.code, - dosageInstruction = medicationDispense.dosageInstruction[0].text, - performer = (medicationDispense.performer[0] as MedicationDispense.MedicationDispensePerformerComponent).actor.identifier.value, - whenHandedOver = medicationDispense.whenHandedOver.convertFhirDateToOffsetDateTime() - ) - } -} - -data class PatientDetail( - val name: String? = null, - val address: String? = null, - val birthdate: LocalDate? = null, - val insuranceIdentifier: String? = null // code == GKV -) - -data class PractitionerDetail( - val name: String? = null, - val qualification: String? = null, - val practitionerIdentifier: String? = null // code == LANR (long term practitioner id) -) - -data class NormSize(val code: String, @StringRes val text: Int?) - -data class MedicationDetail( - val text: String? = null, - @StringRes val type: Int? = null, - val normSize: NormSize? = null, - val uniqueIdentifier: String? = null, // PZN -) - -data class InsuranceCompanyDetail( - val name: String? = null, - @StringRes val status: Int? = null -) - -data class OrganizationDetail( - val name: String? = null, - val address: String? = null, - val uniqueIdentifier: String? = null, // BSNR - val phone: String? = null, - val mail: String? = null -) - -data class MedicationRequestDetail( - val dateOfAccident: LocalDate? = null, // unfalltag - val location: String? = null, // unfallbetrieb - val emergencyFee: Boolean? = null, // emergency service fee = notfallgebuehr - val substitutionAllowed: Boolean = false, - val dosageInstruction: String? = null -) - -fun FhirPatient.mapToUi(): PatientDetail = PatientDetail( - name = this.name.find { it.use == HumanName.NameUse.OFFICIAL }?.nameAsSingleString, - address = this.address.find { it.type == Address.AddressType.BOTH }?.let { - val lines = it.line?.map { l -> l?.value } ?: emptyList() - val address = lines + it.postalCode + it.city - - address.filterNot { a -> a.isNullOrBlank() }.takeIf { a -> a.isNotEmpty() } - ?.joinToString(", ") - }, - birthdate = this.birthDate?.let { LocalDate.from(it.convertFhirDateToLocalDate()) }, - insuranceIdentifier = this.identifier.firstOrNull()?.value -) - -fun FhirPractitioner.mapToUi(): PractitionerDetail = PractitionerDetail( - name = this.name.find { it.use == HumanName.NameUse.OFFICIAL }?.nameAsSingleString, - qualification = this.qualification.find { it.code?.hasText() == true }?.code?.text, - practitionerIdentifier = this.identifier.firstOrNull()?.value -) - -fun FhirMedication.mapToUi(): MedicationDetail = MedicationDetail( - text = this.code?.text, - type = this.form?.coding?.find { it.system == "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM" }?.code?.let { - codeToDosageFormMapping[it] - }, - normSize = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/normgroesse")?.value as? CodeType?)?.value?.let { - NormSize(it, normSizeMapping[it]) - }, - uniqueIdentifier = this.code?.coding?.find { it.system == "http://fhir.de/CodeSystem/ifa/pzn" }?.code, -) - -fun FhirCoverage.mapToUi() = InsuranceCompanyDetail( - name = this.payorFirstRep?.display, - status = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/gkv/versichertenart")?.value as? Coding?)?.code?.let { - statusMapping[it] - }, -) - -fun FhirOrganization.mapToUi() = OrganizationDetail( - name = this.name, - address = this.address.find { it.type == Address.AddressType.BOTH }?.line?.firstOrNull()?.value, - uniqueIdentifier = this.identifier?.find { it.system == "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR" }?.value, - phone = this.telecom?.find { it.system == ContactPoint.ContactPointSystem.PHONE }?.value, - mail = this.telecom?.find { it.system == ContactPoint.ContactPointSystem.EMAIL }?.value -) - -fun FhirMedicationRequest.mapToUi() = MedicationRequestDetail( - dateOfAccident = ( - this.getExtensionByUrl("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident") - ?.getExtensionByUrl("unfalltag")?.value as? DateType? - ) - ?.value - ?.let { LocalDate.from(it.convertFhirDateToLocalDate()) }, - location = ( - this.getExtensionByUrl("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident") - ?.getExtensionByUrl("unfallbetrieb")?.value as? StringType? - )?.value, - emergencyFee = ( - this.getExtensionByUrl("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_EmergencyServicesFee") - ?.value as? BooleanType? - )?.value, - substitutionAllowed = this.substitution.allowedBooleanType.booleanValue(), - dosageInstruction = this.extractDosageInstructions() -) - -fun Bundle.extractPatient() = this.extractResources()?.firstOrNull()?.mapToUi() - -fun Bundle.extractMedication() = - this.extractResources()?.firstOrNull()?.mapToUi() - -fun Bundle.extractMedicationRequest() = - this.extractResources()?.firstOrNull()?.mapToUi() - -fun Bundle.extractPractitioner() = - this.extractResources()?.firstOrNull()?.mapToUi() - -fun Bundle.extractInsurance() = this.extractResources()?.firstOrNull()?.mapToUi() - -fun Bundle.extractOrganization() = - this.extractResources()?.firstOrNull()?.mapToUi() diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionDemoDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionDemoDataSource.kt deleted file mode 100644 index 495e3478..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionDemoDataSource.kt +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.repository - -import de.gematik.ti.erp.app.db.entities.AuditEventSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import java.time.LocalDate -import java.time.OffsetDateTime -import javax.inject.Inject -import javax.inject.Singleton - -private fun demoTasks(now: LocalDate, nowOffset: OffsetDateTime) = listOf( - Task( - taskId = "full detail rezept 1_1", - profileName = "Demo-Profil", - accessCode = "594fd81d9cc3c991f8ffc90b52f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Roser", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(12), - authoredOn = nowOffset.minusDays(2), - medicationText = "Paracetamol Gematikpharm 500mg Tabletten", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 1_2", - profileName = "Demo-Profil", accessCode = "909d3c7a75bce88c6b98854ace5733be213594fd81d9cc3c991f8ffc90b52f12", - organization = "Praxis Dr. Roser", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(12), - authoredOn = nowOffset.minusDays(2), - medicationText = "Ibuprofen 400mg Gematikpharm Filmtabletten", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 2_1", - profileName = "Demo-Profil", accessCode = "594fd81d7cc3c991f8ffc90b52f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Georg Backhaus", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(12), - authoredOn = nowOffset.minusHours(2), - medicationText = "Amoxicillin Gematikpharm 1000 Filmtabletten", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 2_2", - profileName = "Demo-Profil", accessCode = "594fd81d9cb3c991f8ffc90b52f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Georg Backhaus", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(12), - authoredOn = nowOffset.minusHours(2), - medicationText = "Metronidazol Gematikpharm 400 Tabletten", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 2_3", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b52f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Georg Backhaus", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(12), - authoredOn = nowOffset.minusHours(2), - medicationText = "Clotrimazol 1% Creme", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 2_4", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b92f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Georg Backhaus", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(2), - authoredOn = nowOffset.minusHours(2), - medicationText = "Betaisodona Salbe", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 3_1", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b92f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Georg Backhaus", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(10), - authoredOn = nowOffset.minusMinutes(1), - medicationText = "Hyrimoz 40 Mg/0,8 ml Inj.-Lösung", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 3_2", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b92f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Prof. h. c. Dr. med. Schulte", - expiresOn = now.plusDays(70), - acceptUntil = now.plusDays(8), - authoredOn = nowOffset.minusMinutes(1), - medicationText = "Lenalidomid", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 4_1", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b92f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Mortuus est", - expiresOn = now.plusDays(70), - acceptUntil = now, - authoredOn = nowOffset, - medicationText = "Epinephrin 1mg/ml", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 4_2", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b92f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Mortuus est", - expiresOn = now.plusDays(70), - acceptUntil = now.minusDays(4), - authoredOn = nowOffset, - medicationText = "Amiodaron 200 Gematikpharm Tabl.", - rawKBVBundle = "{}".toByteArray() - ), - Task( - taskId = "full detail rezept 4_3", - profileName = "Demo-Profil", accessCode = "594fd82d9cc3c991f8ffc90b92f12909d3c7a75bce88c6b98854ace5733be213", - organization = "Praxis Dr. Mortuus est", - expiresOn = now.plusDays(1), - acceptUntil = now.minusDays(70), - authoredOn = nowOffset, - medicationText = "Vasopressin", - rawKBVBundle = "{}".toByteArray() - ) -) - -private val demoAuditEvents = listOf( - AuditEventSimple(id = "egal", "egal", "P1", "Dr Mortuss hat das Rezept erstellt", OffsetDateTime.now().minusDays(2), "egal"), - AuditEventSimple(id = "egal", "egal", "P2", "Dr Mortuss hat das Rezept übermittelt", OffsetDateTime.now().minusDays(1), "egal") -) - -@Suppress("UNUSED_PARAMETER") -@Singleton -class PrescriptionDemoDataSource @Inject constructor() { - private var _tasks = listOf() - val tasks = MutableStateFlow>(listOf()) - - private var timesRefreshed = 0 - - fun incrementRefresh() { - if (timesRefreshed == 0) { - _tasks = demoTasks(LocalDate.now(), OffsetDateTime.now()) - } - val firstTasks = _tasks.subList(0, 2) - val secondTasks = _tasks.subList(2, 6) - val thirdTasks = _tasks.subList(6, 8) - val fourthTasks = _tasks.subList(8, _tasks.size) - - timesRefreshed += 1 - - when (timesRefreshed) { - 1 -> { - tasks.value += firstTasks - } - 2 -> { - tasks.value += secondTasks - } - 3 -> { - tasks.value += thirdTasks - } - 4 -> { - tasks.value += fourthTasks - } - } - } - - fun saveTasks(tasks: List) { - this.tasks.value += tasks - } - - /** - * Called by [DemoUseCase]. - */ - internal fun reset() { - tasks.value = listOf() - timesRefreshed = 0 - } - - fun deleteTaskByTaskId(taskId: String) { - tasks.value -= tasks.value.filter { task -> - task.taskId == taskId - } - } - - fun loadTaskForTaskId(taskId: String): Flow { - return flowOf(tasks.value.filter { task -> task.taskId == taskId }[0]) - } - - fun loadTasksForScanSessionEnd(scanSessionEnd: OffsetDateTime): Flow> { - return flowOf(tasks.value.filter { task -> task.scanSessionEnd == scanSessionEnd }) - } - - fun redeem(taskIds: List, redeem: Boolean, all: Boolean) { - // TODO: not implemented - } - - fun unRedeemMorePossible(taskId: String): Boolean { - return true - } - - fun getAllTasksWithTaskIdOnly(): List { - return tasks.value.map { it.taskId } - } - - fun loadAuditEvents(taskId: String): Flow> { - return flowOf(demoAuditEvents) - } - - fun editScannedPrescriptionsName(name: String, scanSessionEnd: OffsetDateTime) { - // TODO: not implemented - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt index babfde57..9bd10222 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt @@ -19,31 +19,20 @@ package de.gematik.ti.erp.app.prescription.repository import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.api.map -import de.gematik.ti.erp.app.api.mapCatching -import de.gematik.ti.erp.app.api.mapSuccessful -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.db.entities.TaskStatus -import de.gematik.ti.erp.app.db.entities.TaskWithMedicationDispense -import java.time.OffsetDateTime -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope +import de.gematik.ti.erp.app.fhir.model.extractTaskIds +import de.gematik.ti.erp.app.fhir.parser.findAll +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import okhttp3.ResponseBody -import org.hl7.fhir.r4.model.Communication -import org.hl7.fhir.r4.model.MedicationDispense - -typealias FhirTask = org.hl7.fhir.r4.model.Task -typealias FhirCommunication = Communication +import kotlinx.serialization.json.JsonElement +import java.time.Instant enum class RemoteRedeemOption(val type: String) { Local(type = "onPremise"), @@ -53,227 +42,119 @@ enum class RemoteRedeemOption(val type: String) { const val PROFILE = "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" -const val AUDIT_EVENT_PAGE_SIZE = 50 - -class PrescriptionRepository @Inject constructor( - private val dispatchProvider: DispatchProvider, +class PrescriptionRepository( + private val dispatchers: DispatchProvider, private val localDataSource: LocalDataSource, - private val remoteDataSource: RemoteDataSource, - private val mapper: Mapper + private val remoteDataSource: RemoteDataSource ) { /** * Saves all scanned tasks. It doesn't matter if they already exist. */ - suspend fun saveScannedTasks(tasks: List) { - tasks.forEach { - requireNotNull(it.taskId) - requireNotNull(it.profileName) - requireNotNull(it.scannedOn) - requireNotNull(it.scanSessionEnd) - require(it.rawKBVBundle == null) + suspend fun saveScannedTasks(profileId: ProfileIdentifier, tasks: List) { + withContext(dispatchers.IO) { + localDataSource.saveScannedTasks(profileId, tasks) } - - localDataSource.saveTasks(tasks) } - fun tasks(profileName: String) = localDataSource.loadTasks(profileName) - fun scannedTasksWithoutBundle(profileName: String) = - localDataSource.loadScannedTasksWithoutBundle(profileName) + fun scannedTasks(profileId: ProfileIdentifier) = + localDataSource.loadScannedTasks(profileId).flowOn(dispatchers.IO) - fun syncedTasksWithoutBundle(profileName: String) = - localDataSource.loadSyncedTasksWithoutBundle(profileName) + fun syncedTasks(profileId: ProfileIdentifier) = + localDataSource.loadSyncedTasks(profileId).flowOn(dispatchers.IO) suspend fun redeemPrescription( - profileName: String, - communication: Communication - ): Result { - return remoteDataSource.communicate(profileName, communication) + profileId: ProfileIdentifier, + communication: JsonElement, + accessCode: String? = null + ): Result = withContext(dispatchers.IO) { + remoteDataSource.communicate(profileId, communication, accessCode).map { } } - /** - * Communications will be downloaded and persisted local - */ - suspend fun downloadCommunications(profileName: String): Result = - remoteDataSource.fetchCommunications(profileName).map { - withContext(dispatchProvider.default()) { - val communications = mapper.mapFhirBundleToCommunications(it, profileName) - localDataSource.saveCommunications(communications) - Result.Success(Unit) - } - } - /** * Downloads all tasks and each referenced bundle. Each bundle is persisted locally. */ - suspend fun downloadTasks(profileName: String): Result = - remoteDataSource.fetchTasks(localDataSource.taskSyncedUpTo(profileName), profileName).mapCatching { bundle -> - val taskIds = mapper.parseTaskIds(bundle) - - supervisorScope { - withContext(dispatchProvider.io()) { - taskIds.map { taskId -> - async { - downloadTaskWithKBVBundle(taskId, profileName).map { - deleteLowDetailEvents(taskId) - - if (it.status == TaskStatus.Completed) { - downloadMedicationDispense( - profileName, - taskId - ) + suspend fun downloadTasks(profileId: ProfileIdentifier): Result = + remoteDataSource.fetchTasks(localDataSource.taskSyncedUpTo(profileId).first(), profileId) + .mapCatching { bundle -> + val (_, taskIds) = extractTaskIds(bundle) + + supervisorScope { + withContext(dispatchers.IO) { + val results = taskIds.map { taskId -> + async { + downloadTaskWithKBVBundle(taskId = taskId, profileId = profileId).map { + if (it.isCompleted) { + downloadMedicationDispenses( + profileId, + taskId + ) + } + + requireNotNull(it.lastModified) } - - Result.Success(requireNotNull(it.lastModified)) } - } - } - .awaitAll() - .mapSuccessful { lastModified: List -> - lastModified.maxOrNull()?.let { - localDataSource.updateTaskSyncedUpTo(profileName, it.toInstant()) - } - Result.Success(lastModified.size) - } - } - } - } + }.awaitAll() - private suspend fun downloadTaskWithKBVBundle( - taskId: String, - profileName: String - ): Result = - remoteDataSource.taskWithKBVBundle(profileName, taskId).mapCatching { - val task = mapper.mapFhirBundleToTaskWithKBVBundle(it, profileName) - localDataSource.saveTask(task) - Result.Success(task) - } + // throw if any result is not parsed correctly + results.find { it.isFailure }?.getOrThrow() - private val coroutineScope = CoroutineScope(dispatchProvider.io()) - private val mutex = Mutex() + val lastModified = results.map { it.getOrNull()!! } + lastModified.maxOrNull()?.let { + localDataSource.updateTaskSyncedUpTo(profileId, it) + } - fun downloadAllAuditEvents( - profileName: String - ) { - coroutineScope.launch { - mutex.withLock { - while (true) { - val result = downloadAuditEvents( - profileName = profileName, - count = AUDIT_EVENT_PAGE_SIZE - ) - if (result is Result.Error || (result is Result.Success && result.data != AUDIT_EVENT_PAGE_SIZE)) { - break + // return number of bundles saved to db + lastModified.size } } } - } - } - private suspend fun downloadAuditEvents( - profileName: String, - count: Int? = null - ): Result { - val syncedUpTo = localDataSource.auditEventsSyncedUpTo(profileName) - return remoteDataSource.allAuditEvents( - profileName, - syncedUpTo, - count = count - ).mapCatching { bundle -> - try { - val auditEvents = mapper.mapFhirBundleToAuditEvents(profileName, bundle) - localDataSource.saveAuditEvents(auditEvents) - localDataSource.setAllAuditEventsSyncedUpTo(profileName) - Result.Success(auditEvents.size) - } catch (e: Exception) { - Result.Error(e) - } + private suspend fun downloadTaskWithKBVBundle( + taskId: String, + profileId: ProfileIdentifier + ): Result = withContext(dispatchers.IO) { + remoteDataSource.taskWithKBVBundle(profileId, taskId).mapCatching { bundle -> + requireNotNull(localDataSource.saveTask(profileId, bundle)) } } - private suspend fun downloadMedicationDispense( - profileName: String, + private suspend fun downloadMedicationDispenses( + profileId: ProfileIdentifier, taskId: String - ): Result { - return when (val result = remoteDataSource.medicationDispense(profileName, taskId)) { - is Result.Error -> result - is Result.Success -> { - try { - // FIXME cast can never succeed - mapper.mapMedicationDispenseToMedicationDispenseSimple(result.data as MedicationDispense) - .let { - localDataSource.saveMedicationDispense(it) - localDataSource.updateRedeemedOnForSingleTask( - taskId, - it.whenHandedOver - ) - } - Result.Success(Unit) - } catch (e: Exception) { - Result.Error(e) + ): Result = withContext(dispatchers.IO) { + remoteDataSource.loadBundleOfMedicationDispenses(profileId, taskId).map { bundle -> + bundle.findAll("entry.resource") + .forEach { dispense -> + localDataSource.saveMedicationDispense(taskId, dispense) } - } } } - suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) { - localDataSource.saveLowDetailEvent(lowDetailEvent) - } - - fun loadLowDetailEvents(taskId: String): Flow> = - localDataSource.loadLowDetailEvents(taskId) - - fun deleteLowDetailEvents(taskId: String) { - localDataSource.deleteLowDetailEvents(taskId) - } - suspend fun deleteTaskByTaskId( - profileName: String, - taskId: String, - isRemoteTask: Boolean - ): Result { - val result = if (isRemoteTask) { - when (val result = remoteDataSource.deleteTask(profileName, taskId)) { - is Result.Success -> { - Result.Success(Unit) - } - is Result.Error -> result - } + profileId: ProfileIdentifier, + taskId: String + ): Result = withContext(dispatchers.IO) { + // check if task is local only + if (localDataSource.loadScannedTaskByTaskId(taskId).first() != null) { + localDataSource.deleteTask(taskId) + Result.success(Unit) } else { - Result.Success(Unit) - } - - if (result is Result.Success) { - localDataSource.deleteTaskByTaskId(taskId) + remoteDataSource.deleteTask(profileId, taskId).map { + localDataSource.deleteTask(taskId) + } } - return result - } - - suspend fun updateRedeemedOnForAllTasks(taskIds: List, tm: OffsetDateTime?) { - localDataSource.updateRedeemedOnForAllTasks(taskIds, tm) - } - - suspend fun updateRedeemedOnForSingleTask(taskId: String, tm: OffsetDateTime?) { - localDataSource.updateRedeemedOnForSingleTask(taskId, tm) - } - - fun loadTasksForRedeemedOn(redeemedOn: OffsetDateTime, profileName: String): Flow> { - return localDataSource.loadTasksForRedeemedOn(redeemedOn, profileName) } - fun loadTaskWithMedicationDispenseForTaskId(taskId: String): Flow { - return localDataSource.loadTaskWithMedicationDispenseForTaskId(taskId) + suspend fun updateRedeemedOn(taskId: String, timestamp: Instant?) = withContext(dispatchers.IO) { + localDataSource.updateRedeemedOn(taskId, timestamp) } - fun loadTasksForTaskId(vararg taskIds: String): Flow> { - return localDataSource.loadTasksForTaskId(*taskIds) - } + fun loadSyncedTaskByTaskId(taskId: String): Flow = + localDataSource.loadSyncedTaskByTaskId(taskId).flowOn(dispatchers.IO) - suspend fun getAllTasksWithTaskIdOnly(profileName: String): List { - return localDataSource.getAllTasksWithTaskIdOnly(profileName) - } + fun loadScannedTaskByTaskId(taskId: String): Flow = + localDataSource.loadScannedTaskByTaskId(taskId).flowOn(dispatchers.IO) - fun updateScanSessionName(name: String?, scanSessionEnd: OffsetDateTime) { - localDataSource.updateScanSessionName(name, scanSessionEnd) - } + fun loadTaskIds(): Flow> = localDataSource.loadTaskIds().flowOn(dispatchers.IO) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSource.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSource.kt index 49d7e71a..a807d636 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSource.kt @@ -19,77 +19,65 @@ package de.gematik.ti.erp.app.prescription.repository import de.gematik.ti.erp.app.api.ErpService -import de.gematik.ti.erp.app.api.Result import de.gematik.ti.erp.app.api.safeApiCall -import de.gematik.ti.erp.app.db.converter.DateConverter -import java.time.OffsetDateTime -import javax.inject.Inject -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Communication +import de.gematik.ti.erp.app.api.safeApiCallNullable +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.serialization.json.JsonElement import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit -class RemoteDataSource @Inject constructor( +class RemoteDataSource( private val service: ErpService ) { // greater _than_, otherwise we query the same resource again private fun gtString(timestamp: Instant) = - "gt${timestamp.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)}" + "gt${timestamp.atOffset(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)}" - suspend fun fetchTasks(lastKnownUpdate: Instant?, profileName: String): Result = + suspend fun fetchTasks(lastKnownUpdate: Instant?, profileId: ProfileIdentifier): Result = safeApiCall( "error while loading tasks" ) { val dateTimeString = lastKnownUpdate?.let { gtString(it) } - service.allTasks(profileName, dateTimeString) + service.allTasks(profileId, dateTimeString) } - suspend fun fetchCommunications(profileName: String): Result = safeApiCall( + suspend fun fetchCommunications( + profileId: ProfileIdentifier, + count: Int?, + lastKnownUpdate: String? + ): Result = safeApiCall( errorMessage = "error getting communications" ) { - service.communication(profileName) - } - - suspend fun taskWithKBVBundle(profileName: String, taskID: String) = safeApiCall( - errorMessage = "error while downloading KBV Bundle $taskID" - ) { service.taskWithKBVBundle(profileName = profileName, id = taskID) } - - suspend fun allAuditEvents( - profileName: String, - lastKnownUpdate: OffsetDateTime?, - count: Int? = null, - offset: Int? = null - ) = safeApiCall( - errorMessage = "Error getting all Audit Events" - ) { - val dateTimeString: String? = - lastKnownUpdate?.let { "gt${DateConverter().fromOffsetDateTime(it)}" } - service.allAuditEvents( - profileName = profileName, - lastKnownDate = dateTimeString, + service.getCommunications( + profileId = profileId, count = count, - offset = offset + lastKnownDate = lastKnownUpdate ) } - suspend fun medicationDispense(profileName: String, taskId: String) = safeApiCall( + suspend fun taskWithKBVBundle(profileId: ProfileIdentifier, taskID: String) = safeApiCall( + errorMessage = "error while downloading KBV Bundle $taskID" + ) { service.taskWithKBVBundle(profileId = profileId, id = taskID) } + + suspend fun loadBundleOfMedicationDispenses(profileId: ProfileIdentifier, taskId: String) = safeApiCall( errorMessage = "Error getting medication dispenses" ) { - service.medicationDispense(profileName, id = taskId) + val id = "https://gematik.de/fhir/NamingSystem/PrescriptionID|$taskId" + service.bundleOfMedicationDispenses(profileId, id = id) } - suspend fun deleteTask(profileName: String, taskId: String) = safeApiCall( + suspend fun deleteTask(profileId: ProfileIdentifier, taskId: String) = safeApiCallNullable( "error deleting task $taskId" ) { - service.deleteTask(profileName, id = taskId) + service.deleteTask(profileId, id = taskId) } - suspend fun communicate(profileName: String, com: Communication) = safeApiCall( - errorMessage = "error while posting communication" - ) { - service.communication(profileName, communication = com) - } + suspend fun communicate(profileId: ProfileIdentifier, communication: JsonElement, accessCode: String? = null) = + safeApiCall(errorMessage = "error while posting communication") { + service.postCommunication(profileId = profileId, communication = communication, accessCode = accessCode) + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ArchiveScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ArchiveScreen.kt new file mode 100644 index 00000000..b1ded49a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ArchiveScreen.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens +import de.gematik.ti.erp.app.prescription.ui.model.PrescriptionScreenData +import de.gematik.ti.erp.app.prescription.usecase.model.PrescriptionUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun ArchiveScreen(prescriptionViewModel: PrescriptionViewModel, navController: NavController, onBack: () -> Unit) { + val listState = rememberLazyListState() + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.archive_screen_title), + listState = listState, + onBack = onBack, + navigationMode = NavigationBarMode.Back + ) { + val state by produceState(null) { + prescriptionViewModel.screenState().collect { + value = it + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { SpacerXXLarge() } + + state?.let { + it.redeemedPrescriptions.forEachIndexed { index, prescription -> + item { + val previousPrescriptionRedeemedOn = + it.redeemedPrescriptions.getOrNull(index - 1) + ?.redeemedOrExpiredOn() + ?.atZone(ZoneId.systemDefault())?.toLocalDate() + + val redeemedOn = prescription.redeemedOrExpiredOn() + .atZone(ZoneId.systemDefault()).toLocalDate() + + val yearChanged = remember { + previousPrescriptionRedeemedOn?.year != redeemedOn.year + } + + if (yearChanged) { + val instantOfArchivedPrescription = remember { + val dateFormatter = DateTimeFormatter.ofPattern("yyyy") + redeemedOn.format(dateFormatter) + } + + Text( + text = instantOfArchivedPrescription, + style = AppTheme.typography.h6, + modifier = Modifier + .fillMaxWidth() + .then(CardPaddingModifier) + ) + } + + when (prescription) { + is PrescriptionUseCaseData.Prescription.Scanned -> + LowDetailMedication( + modifier = CardPaddingModifier, + prescription, + onClick = { + navController.navigate( + MainNavigationScreens.PrescriptionDetail.path( + taskId = prescription.taskId + ) + ) + } + ) + + is PrescriptionUseCaseData.Prescription.Synced -> + FullDetailMedication( + prescription, + modifier = CardPaddingModifier, + onClick = { + navController.navigate( + MainNavigationScreens.PrescriptionDetail.path( + taskId = prescription.taskId + ) + ) + } + ) + } + } + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/EmptyScreenTemplate.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/EmptyScreenTemplate.kt new file mode 100644 index 00000000..f79c0901 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/EmptyScreenTemplate.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall + +@Composable +fun EmptyScreenHome( + modifier: Modifier = Modifier, + header: String, + description: String, + image: @Composable () -> Unit, + button: @Composable () -> Unit +) { + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + image() + SpacerMedium() + Text( + text = header, + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().wrapContentHeight() + ) + SpacerSmall() + Text( + text = description, + style = AppTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().wrapContentHeight() + ) + SpacerSmall() + button() + } +} + +@Composable +fun EmptyScreenArchive( + modifier: Modifier = Modifier, + header: String, + description: String +) { + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Text( + text = header, + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + SpacerSmall() + Text( + text = description, + style = AppTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +fun ConnectButton( + onClick: () -> Unit +) = + TextButton( + onClick = onClick + ) { + Icon( + Icons.Rounded.Refresh, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + SpacerSmall() + Text(text = "Verbinden", textAlign = TextAlign.Right) + } + +@Composable +fun RefreshButton( + onClick: () -> Unit +) = + TextButton( + onClick = onClick + ) { + Icon( + Icons.Rounded.Refresh, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + SpacerSmall() + Text(text = "Aktualisieren", textAlign = TextAlign.Right) + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/EmptyScreens.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/EmptyScreens.kt new file mode 100644 index 00000000..3d3d7616 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/EmptyScreens.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.QrCode +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.SpacerSmall + +@Composable +fun HomeConnectedWithoutTokenBiometrics( + modifier: Modifier = Modifier, + onClickAction: () -> Unit +) = + EmptyScreenHome( + modifier = modifier, + header = stringResource(R.string.main_empty_screen_connect_now_title), + description = stringResource(R.string.main_empty_screen_connect_now), + image = { + Image( + painterResource(R.drawable.clapping_hands_blue), + contentDescription = null, + modifier = Modifier.size(160.dp) + ) + }, + button = { + ConnectButton( + onClick = onClickAction + ) + } + ) + +@Composable +fun HomeConnectedWithoutToken( + modifier: Modifier = Modifier, + onClickAction: () -> Unit +) = + EmptyScreenHome( + modifier = modifier, + header = stringResource(R.string.main_empty_screen_tokens_removed_connect_now_title), + description = stringResource(R.string.main_empty_screen_tokens_removed_connect_now), + image = { + Image( + painterResource(R.drawable.girl_red_oh_no), + contentDescription = null, + modifier = Modifier.size(160.dp) + ) + }, + button = { + ConnectButton( + onClick = onClickAction + ) + } + ) + +@Composable +fun HomeHealthCardConnected( + modifier: Modifier = Modifier, + onClickAction: () -> Unit +) = + EmptyScreenHome( + modifier = modifier, + header = stringResource(R.string.home_egk_redeemed_header), + description = stringResource(R.string.home_egk_redeemed_description), + image = { + Image( + painterResource(R.drawable.woman_red_shirt_circle_blue), + contentDescription = null, + modifier = Modifier.size(160.dp) + ) + }, + button = { + TextButton( + onClick = onClickAction + ) { + Icon( + Icons.Rounded.Refresh, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + SpacerSmall() + Text(text = stringResource(R.string.home_egk_redeemed_buttontext), textAlign = TextAlign.Right) + } + } + ) + +@Preview +@Composable +private fun HomeHealthCardConnectedPreview() { + AppTheme { + HomeHealthCardConnected(onClickAction = {}) + } +} + +@Composable +fun HomeHealthCardDisconnected( + modifier: Modifier = Modifier, + onClickAction: () -> Unit +) = + EmptyScreenHome( + modifier = modifier, + header = stringResource(R.string.home_egk_notredeemable_header), + description = stringResource(R.string.home_egk_notredeemable_description), + image = { + Image( + painterResource(R.drawable.alarm_clock), + contentDescription = null + ) + }, + button = { + TextButton( + onClick = onClickAction + ) { + Icon( + Icons.Rounded.Refresh, + null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + SpacerSmall() + Text(text = stringResource(R.string.home_egk_notredeemable_buttontext), textAlign = TextAlign.Right) + } + } + ) + +@Preview +@Composable +private fun HomeHealthCardDisconnectedPreview() { + AppTheme { + HomeHealthCardDisconnected(onClickAction = {}) + } +} + +@Composable +fun HomeNoHealthCard( + modifier: Modifier = Modifier, + onClickAction: () -> Unit +) = EmptyScreenHome( + modifier = modifier, + header = stringResource(R.string.home_noegk_initial_header), + description = stringResource(R.string.home_noegk_initial_description), + image = { + Image( + painterResource(R.drawable.prescription), + contentDescription = null + ) + }, + button = { + TextButton(onClick = onClickAction) { + Icon( + imageVector = Icons.Rounded.QrCode, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.primary600 + ) + SpacerSmall() + Text(text = stringResource(R.string.home_noegk_initial_buttontext), textAlign = TextAlign.Right) + } + } +) + +@Preview +@Composable +private fun HomeNoHealthCardPreview() { + AppTheme { + HomeNoHealthCard(onClickAction = {}) + } +} + +@Composable +fun ArchiveNoHealthCardInitial(modifier: Modifier = Modifier) = EmptyScreenArchive( + modifier = modifier, + header = stringResource(R.string.archive_noegk_initial_header), + description = stringResource(R.string.archive_noegk_initial_description) +) + +@Composable +fun ArchiveNoHealthCardRedeemed(modifier: Modifier = Modifier) = EmptyScreenArchive( + modifier = modifier, + header = stringResource(R.string.archive_noegk_redeemed_header), + description = stringResource(R.string.archive_noegk_redeemed_description) +) + +@Preview +@Composable +private fun ArchiveNoEGKInitialPreview() { + AppTheme { + ArchiveNoHealthCardInitial() + } +} + +@Preview +@Composable +private fun ArchiveNoEGKRedeemedPreview() { + AppTheme { + ArchiveNoHealthCardRedeemed() + } +} + +@Composable +fun HomeNoHealthCardSignInHint(onClickAction: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RectangleShape, + backgroundColor = AppTheme.colors.neutral050 + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) { + Text( + text = stringResource(R.string.home_noegk_signin_hint_description), + modifier = Modifier + .padding(vertical = PaddingDefaults.Medium) + .fillMaxWidth() + .weight(1f), + style = AppTheme.typography.body2 + ) + TextButton( + onClick = onClickAction, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = AppTheme.colors.primary600), + modifier = Modifier.align(Alignment.CenterVertically).testTag(TestTag.Main.LoginButton), + contentPadding = PaddingValues(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.Tiny) + ) { + Text( + text = stringResource(R.string.home_noegk_signin_hint_buttontext), + textAlign = TextAlign.Center, + style = AppTheme.typography.button + ) + } + } + } +} + +@Preview +@Composable +private fun SignInHintPreview() { + AppTheme { + HomeNoHealthCardSignInHint({}) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/Hints.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/Hints.kt deleted file mode 100644 index 79093f11..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/Hints.kt +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.AnimatedHintCard -import de.gematik.ti.erp.app.utils.compose.HintActionButton -import de.gematik.ti.erp.app.utils.compose.HintSmallImage -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton -import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource - -@Composable -fun PrescriptionScreenDemoModeActivatedCard( - modifier: Modifier = Modifier, - onClose: suspend () -> Unit -) { - AnimatedHintCard( - modifier = modifier, - onTransitionEnd = { - if (!it) { - onClose() - } - }, - image = { - HintSmallImage( - painterResource(R.drawable.clapping_hands_hint_yellow), - null, - it - ) - }, - title = { Text(stringResource(R.string.prescription_overview_hint_welcome_to_demo_headline)) }, - body = { Text(stringResource(R.string.prescription_overview_hint_welcome_to_demo_text)) }, - action = null - ) -} - -@Preview -@Composable -private fun PrescriptionScreenDemoModeActivatedPreview() { - AppTheme { - PrescriptionScreenDemoModeActivatedCard(Modifier, {}) - } -} - -@Composable -fun PrescriptionScreenTryDemoModeCard( - modifier: Modifier = Modifier, - onClickAction: () -> Unit, - onClose: suspend () -> Unit -) { - AnimatedHintCard( - modifier = modifier, - onTransitionEnd = { - if (!it) { - onClose() - } - }, - image = { HintSmallImage(painterResource(R.drawable.health_card_hint_blue), null, it) }, - title = { Text(stringResource(R.string.prescription_overview_hint_link_to_demo_mode_headline)) }, - body = { Text(stringResource(R.string.prescription_overview_hint_link_to_demo_mode_text)) }, - action = { - HintTextActionButton( - stringResource(R.string.prescription_overview_hint_link_to_demo_mode_call_to_action_text), - onClick = onClickAction - ) - }, - ) -} - -@Preview -@Composable -private fun PrescriptionScreenTryDemoModePreview() { - AppTheme { - PrescriptionScreenTryDemoModeCard(Modifier, {}, {}) - } -} - -@Composable -fun PrescriptionScreenDefineSecurityCard( - modifier: Modifier = Modifier, - onClickAction: () -> Unit -) { - AnimatedHintCard( - modifier = modifier, - onTransitionEnd = {}, - image = { - HintSmallImage( - painterResource(R.drawable.pharmacist_with_phone_hint_blue), - null, - it - ) - }, - title = { Text(stringResource(R.string.prescription_overview_hint_define_security_headline)) }, - body = { Text(stringResource(R.string.prescription_overview_hint_define_security_text)) }, - action = { - HintTextActionButton( - stringResource(R.string.prescription_overview_hint_define_security_call_to_action_text), - onClick = onClickAction - ) - }, - close = null - ) -} - -@Preview -@Composable -private fun PrescriptionScreenDefineSecurityPreview() { - AppTheme { - PrescriptionScreenDefineSecurityCard(Modifier, {}) - } -} - -@Composable -fun PrescriptionScreenNewPrescriptionsCard( - modifier: Modifier = Modifier, - countOfNewPrescriptions: Int, - onClickAction: () -> Unit -) { - AnimatedHintCard( - modifier = modifier, - onTransitionEnd = {}, - image = { - Box( - modifier = Modifier - .padding(it) - .align(Alignment.Top) - ) { - Image( - painterResource(R.drawable.medical_hand_out_circle_blue), - null, - modifier = Modifier - .size(80.dp) - ) - Box( - modifier = Modifier - .background( - // color = AppTheme.colors.red600, - shape = CircleShape, - brush = Brush.linearGradient( - 0.0f to AppTheme.colors.red700, - 0.6f to AppTheme.colors.red600, - 1.0f to AppTheme.colors.red500, - start = Offset(0.0f, 100.0f), - end = Offset(100.0f, 0.0f) - ) - ) - .size(32.dp) - .align( - Alignment.BottomEnd - ) - ) { - Text( - countOfNewPrescriptions.toString(), - modifier = Modifier.align(Alignment.Center), - color = AppTheme.colors.neutral000, - style = MaterialTheme.typography.h6 - ) - } - } - }, - title = { - Text( - annotatedPluralsResource( - R.plurals.prescription_overview_hint_new_prescriptions_headline, - countOfNewPrescriptions, - AnnotatedString(countOfNewPrescriptions.toString()) - ) - ) - }, - body = { Text(stringResource(R.string.prescription_overview_hint_new_prescriptions_text)) }, - action = { - HintActionButton( - stringResource(R.string.prescription_overview_hint_new_prescriptions_call_to_action_text), - onClick = onClickAction - ) - }, - close = null - ) -} - -@Preview -@Composable -private fun PrescriptionScreenNewPrescriptionsCardPreview() { - AppTheme { - PrescriptionScreenNewPrescriptionsCard(Modifier, 1, {}) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/MainScreenAvatar.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/MainScreenAvatar.kt new file mode 100644 index 00000000..f827a458 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/MainScreenAvatar.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddAPhoto +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.profiles.ui.ChooseAvatar +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.profileColor +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.TertiaryButton + +@Composable +fun SmallMainScreenAvatar(onClickAvatar: () -> Unit) { + val profileHandler = LocalProfileHandler.current + val profile = profileHandler.activeProfile + val ssoTokenScope = profile.ssoTokenScope + + val currentSelectedColors = profileColor(profileColorNames = profile.color) + + Box( + modifier = Modifier, + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.size(40.dp), + shape = CircleShape, + color = currentSelectedColors.backGroundColor + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClickAvatar), + contentAlignment = Alignment.Center + ) { + ChooseAvatar( + iconModifier = Modifier.size(16.dp), + emptyIcon = Icons.Rounded.AddAPhoto, + profile = profile, + figure = profile.avatarFigure + ) + } + } + if (profile.lastAuthenticated != null) { + Box( + modifier = Modifier + .size(24.dp) + .align(Alignment.BottomEnd) + .offset(8.dp, 8.dp) + .clip(CircleShape) + .aspectRatio(1f) + .border( + 2.dp, + AppTheme.colors.neutral000, + CircleShape + ) + + ) { + when { + ssoTokenScope?.token?.isValid() == true -> { + Image( + painterResource(R.drawable.main_screen_erx_icon_small), + null, + modifier = Modifier.offset(2.dp, 2.dp) + ) + } + else -> { + Image( + painterResource(R.drawable.main_screen_erx_icon_gray_small), + null, + modifier = Modifier.offset(2.dp, 2.dp) + ) + } + } + } + } + } +} + +@Composable +fun MainScreenAvatar(onClickAvatar: () -> Unit) { + val profileHandler = LocalProfileHandler.current + val profile = profileHandler.activeProfile + val ssoTokenScope = profile.ssoTokenScope + + val currentSelectedColors = profileColor(profileColorNames = profile.color) + + Box( + modifier = Modifier, + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.size(96.dp), + shape = CircleShape, + color = currentSelectedColors.backGroundColor + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClickAvatar), + contentAlignment = Alignment.Center + ) { + ChooseAvatar( + iconModifier = Modifier.size(24.dp), + emptyIcon = Icons.Rounded.AddAPhoto, + profile = profile, + figure = profile.avatarFigure + ) + } + } + if (profile.lastAuthenticated != null) { + Box( + modifier = Modifier + .size(40.dp) + .align(Alignment.BottomEnd) + .offset(12.dp, 12.dp) + .clip(CircleShape) + .aspectRatio(1f) + .border( + 4.dp, + AppTheme.colors.neutral000, + CircleShape + ) + + ) { + when { + ssoTokenScope?.token?.isValid() == true -> { + Image( + painterResource(R.drawable.main_screen_erx_icon_large), + null, + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + Image( + painterResource(R.drawable.main_screen_erx_icon_gray_large), + null, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } + } +} + +@Composable +fun ProfileConnectionSection(onClickAvatar: () -> Unit, onClickRefresh: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium), + horizontalArrangement = Arrangement.SpaceBetween + ) { + SmallMainScreenAvatar(onClickAvatar) + ConnectionHelper(onClickRefresh) + } +} + +@Composable +fun ConnectionHelper(onClickRefresh: () -> Unit) { + val profileHandler = LocalProfileHandler.current + val profile = profileHandler.activeProfile + val ssoTokenScope = profile.ssoTokenScope + + if (profile.lastAuthenticated != null && ssoTokenScope?.token == null) { + TertiaryButton(onClickRefresh) { + Text(stringResource(R.string.mainscreen_login)) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreenComponents.kt index ef3b3333..50e37f3f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreenComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreenComponents.kt @@ -18,15 +18,9 @@ package de.gematik.ti.erp.app.prescription.ui -import android.net.Uri -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -34,754 +28,729 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.Card -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.SwipeableState import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Update -import androidx.compose.material.rememberSwipeableState -import androidx.compose.material.swipeable +import androidx.compose.material.icons.rounded.ArrowDownward +import androidx.compose.material.icons.rounded.WarningAmber import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.ui.unit.em import androidx.navigation.NavController import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintDefineSecurity -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintDemoModeActivated -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintTryDemoMode -import de.gematik.ti.erp.app.core.LocalActivity -import de.gematik.ti.erp.app.demo.ui.DemoBanner +import de.gematik.ti.erp.app.TestTag + import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens import de.gematik.ti.erp.app.mainscreen.ui.MainScreenViewModel -import de.gematik.ti.erp.app.mainscreen.ui.PullRefreshState +import de.gematik.ti.erp.app.mainscreen.ui.MlKitPermissionDialog +import de.gematik.ti.erp.app.mainscreen.ui.RefreshScaffold +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData import de.gematik.ti.erp.app.prescription.ui.model.PrescriptionScreenData import de.gematik.ti.erp.app.prescription.usecase.model.PrescriptionUseCaseData -import de.gematik.ti.erp.app.settings.ui.SettingsScrollTo +import de.gematik.ti.erp.app.prescriptionId +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.ProfileHandler import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.NavigationAnimation -import de.gematik.ti.erp.app.utils.compose.NavigationMode +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.DynamicText +import de.gematik.ti.erp.app.utils.compose.SpacerLarge import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerShortMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.dateWithIntroductionString import de.gematik.ti.erp.app.utils.compose.SpacerTiny +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge +import de.gematik.ti.erp.app.utils.compose.TertiaryButton import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import de.gematik.ti.erp.app.utils.compose.dateString +import de.gematik.ti.erp.app.utils.compose.phrasedDateString +import de.gematik.ti.erp.app.utils.compose.timeString import java.time.Duration -import java.time.LocalDate +import java.time.Instant import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.ZoneId -import java.time.ZoneOffset import java.time.format.DateTimeFormatter -import kotlin.math.roundToInt -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber +import java.time.temporal.ChronoUnit + +const val ZERO_DAYS_LEFT = 0L +const val ONE_DAY_LEFT = 1L +const val TWO_DAYS_LEFT = 2L @Composable -fun SecureHardwarePrompt( - title: String, - description: String, - negativeButton: String, - onAuthenticate: () -> Unit, - onCancel: () -> Unit, +fun PrescriptionScreen( + navController: NavController, + prescriptionViewModel: PrescriptionViewModel, + mainScreenViewModel: MainScreenViewModel, + onClickAvatar: () -> Unit, + onClickArchive: () -> Unit, + onElevateTopBar: (Boolean) -> Unit ) { - val activity = LocalActivity.current as FragmentActivity - - val executor = remember { ContextCompat.getMainExecutor(activity) } - - val callback = remember { - object : BiometricPrompt.AuthenticationCallback() { - - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { - super.onAuthenticationSucceeded(result) + val profileHandler = LocalProfileHandler.current + val profileId = profileHandler.activeProfile.id - onAuthenticate() - } - - override fun onAuthenticationError( - errCode: Int, - errString: CharSequence - ) { - super.onAuthenticationError(errCode, errString) + var showUserNotAuthenticatedDialog by remember { mutableStateOf(false) } - Timber.e("Failed to authenticate: $errString") - - onCancel() - } - } + val onShowCardWall = { + navController.navigate( + MainNavigationScreens.CardWall.path(profileHandler.activeProfile.id) + ) } - val prompt = remember { BiometricPrompt(activity, executor, callback) } - val promptInfo = remember { - BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setDescription(description) - .setNegativeButtonText(negativeButton) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) - .build() + if (showUserNotAuthenticatedDialog) { + UserNotAuthenticatedDialog( + onCancel = { showUserNotAuthenticatedDialog = false }, + onShowCardWall = onShowCardWall + ) } - DisposableEffect(prompt) { - prompt.authenticate(promptInfo) + RefreshScaffold( + profileId = profileId, + onUserNotAuthenticated = { showUserNotAuthenticatedDialog = true }, + mainScreenViewModel = mainScreenViewModel, + onShowCardWall = onShowCardWall + ) { onRefresh -> + Prescriptions( + prescriptionViewModel = prescriptionViewModel, + onClickRefresh = { + onRefresh(true, MutatePriority.UserInput) + }, + onClickAvatar = onClickAvatar, + navController = navController, + onElevateTopBar = onElevateTopBar, + onClickArchive = onClickArchive + ) + } +} - onDispose { - prompt.cancelAuthentication() - } +@Composable +fun UserNotAuthenticatedDialog(onCancel: () -> Unit, onShowCardWall: () -> Unit) { + CommonAlertDialog( + header = stringResource(R.string.user_not_authenticated_dialog_header), + info = stringResource(R.string.user_not_authenticated_dialog_info), + cancelText = stringResource(R.string.user_not_authenticated_dialog_cancel), + actionText = stringResource(R.string.user_not_authenticated_dialog_connect), + onCancel = onCancel + ) { + onShowCardWall() } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) +val CardPaddingModifier = Modifier + .padding( + bottom = PaddingDefaults.Medium, + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + .fillMaxWidth() + @Composable -fun PrescriptionScreen( +private fun Prescriptions( + prescriptionViewModel: PrescriptionViewModel, navController: NavController, - mainViewModel: MainScreenViewModel = hiltViewModel(LocalActivity.current), - prescriptionViewModel: PrescriptionViewModel = hiltViewModel(), - uri: Uri? + onClickRefresh: () -> Unit, + onClickAvatar: () -> Unit, + onClickArchive: () -> Unit, + onElevateTopBar: (Boolean) -> Unit ) { - val refreshState = rememberSwipeableState(false) - - var pullRefreshState by remember { mutableStateOf(PullRefreshState.None) } - LaunchedEffect(Unit) { - // we need a short delay until layout calculations are done; - // otherwise we will run into the anchor null problem of the swipeable state - delay(100) - mainViewModel.refreshState().collect { - pullRefreshState = it - when (pullRefreshState) { - PullRefreshState.IsFirstTimeBiometricAuthentication, - PullRefreshState.HasFirstTimeValidToken, - PullRefreshState.HasValidToken -> { - refreshState.animateTo(true) - } - else -> { - refreshState.snapTo(false) - } - } + val state by produceState(null) { + prescriptionViewModel.screenState().collect { + value = it } } - var showSecureHardwarePrompt by remember { mutableStateOf(false) } - if (showSecureHardwarePrompt) { - SecureHardwarePrompt( - stringResource(R.string.alternate_auth_header), - stringResource(R.string.alternate_auth_info), - stringResource(R.string.cancel), - onAuthenticate = { - prescriptionViewModel.onAlternateAuthentication() - showSecureHardwarePrompt = false + state?.let { + PrescriptionsContent( + onClickRefresh = onClickRefresh, + onClickAvatar = onClickAvatar, + state = it, + navController = navController, + onElevateTopBar = onElevateTopBar, + onClickArchive = onClickArchive + ) + } +} + +private val FabPadding = 68.dp + +@Composable +private fun PrescriptionsContent( + onClickRefresh: () -> Unit, + onClickAvatar: () -> Unit, + onClickArchive: () -> Unit, + state: PrescriptionScreenData.State, + navController: NavController, + onElevateTopBar: (Boolean) -> Unit +) { + val listState = rememberLazyListState() + val profileHandler = LocalProfileHandler.current + var showMlKitPermissionDialog by remember { mutableStateOf(false) } + + if (showMlKitPermissionDialog) { + MlKitPermissionDialog( + onAccept = { + navController.navigate(MainNavigationScreens.Camera.path()) + showMlKitPermissionDialog = false }, - onCancel = { showSecureHardwarePrompt = false } + onDecline = { + showMlKitPermissionDialog = false + } ) } - val state by produceState(prescriptionViewModel.defaultState) { - prescriptionViewModel.screenState().collect { - value = it + LaunchedEffect(Unit) { + snapshotFlow { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + }.collect { + onElevateTopBar(it) } } - LaunchedEffect(refreshState.currentValue) { - try { - if (refreshState.currentValue) { - prescriptionViewModel.refreshPrescriptions( - pullRefreshState = pullRefreshState, - isDemoModeActive = state.showDemoBanner, - onShowSecureHardwarePrompt = { - showSecureHardwarePrompt = true - }, - onShowCardWall = { canAvailable -> - withContext(Dispatchers.Main) { - // TODO: find a better way - pullRefreshState = PullRefreshState.None - refreshState.snapTo(false) - - navController.navigate( - MainNavigationScreens.CardWall.path( - canAvailable - ) - ) - } - }, - onRefresh = mainViewModel::onRefresh - ) + LazyColumn( + modifier = Modifier.fillMaxSize().testTag(TestTag.Prescriptions.Content), + state = listState, + contentPadding = if (state.prescriptions.isNotEmpty()) { + PaddingValues(0.dp) + } else { + PaddingValues(bottom = FabPadding) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + if (state.prescriptions.isNotEmpty()) { + item { + SpacerXXLarge() + ProfileConnectionSection(onClickAvatar, onClickRefresh) + SpacerMedium() } - } finally { - withContext(NonCancellable) { - pullRefreshState = PullRefreshState.None - refreshState.animateTo(false) + prescriptionContent( + state = state, + navController = navController + ) + } else { + emptyContent(profileHandler, onClickRefresh, onClickAvatar) + } + if (state.redeemedPrescriptions.isNotEmpty()) { + item { + SpacerLarge() + TextButton(onClick = onClickArchive) { + Text(stringResource(R.string.archived_prescriptions_button)) + } + SpacerLarge() } } } +} - PullRefresh(refreshState) { - val modifier = Modifier - Box { - Column(modifier = Modifier.fillMaxSize()) { - if (state.showDemoBanner) { - DemoBanner { - mainViewModel.onDeactivateDemoMode() - } - } - - NavigationAnimation(mode = NavigationMode.Open) { - val coroutineScope = rememberCoroutineScope() - - Prescriptions( - onClickRefresh = { - coroutineScope.launch { refreshState.animateTo(true) } - }, - prescriptionViewModel = prescriptionViewModel, - state = state, - navController = navController, - uri = uri - ) - } - } - // todo FastTrack: combine success/error result from FastTrack auth process with app. - // Processing = banner, Error = Dialog? - uri?.let { - var showBanner by remember { mutableStateOf(true) } - if (showBanner) Banner(modifier.align(Alignment.BottomEnd)) { showBanner = false } -// mainViewModel.onExternAppAuthorizationResult(it) +fun LazyListScope.emptyContent( + profileHandler: ProfileHandler, + onClickConnect: () -> Unit, + onClickAvatar: () -> Unit +) { + item { + Spacer(modifier = Modifier.size(80.dp)) + MainScreenAvatar(onClickAvatar) + } + if (profileHandler.connectionState(profileHandler.activeProfile) != + ProfileHandler.ProfileConnectionState.LoggedIn + ) { + item { + SpacerMedium() + TertiaryButton(onClickConnect) { + Text(stringResource(R.string.mainscreen_login)) } } + item { + SpacerLarge() + Text(stringResource(R.string.mainscreen_empty_content_header), style = AppTheme.typography.subtitle1) + } + item { + SpacerSmall() + Text( + stringResource(R.string.mainscreen_empty_not_connected_info), + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + style = AppTheme.typography.body2, + textAlign = TextAlign.Center + ) + } + } else { + item { + SpacerLarge() + Text(stringResource(R.string.mainscreen_empty_content_header), style = AppTheme.typography.subtitle1) + } + item { + SpacerMedium() + Text( + stringResource(R.string.mainscreen_empty_connected_info), + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + style = AppTheme.typography.body2, + textAlign = TextAlign.Center + ) + SpacerMedium() + Icon(Icons.Rounded.ArrowDownward, null) + } } } -@Composable -private fun Prescriptions( - prescriptionViewModel: PrescriptionViewModel, - state: PrescriptionScreenData.State, +private fun LazyListScope.prescriptionContent( navController: NavController, - onClickRefresh: () -> Unit, - uri: Uri? + state: PrescriptionScreenData.State ) { - val cardPaddingModifier = Modifier - .padding( - bottom = PaddingDefaults.Medium, - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium - ) - .fillMaxWidth() - val headerPaddingModifier = Modifier - .padding( - top = PaddingDefaults.XLarge, - bottom = PaddingDefaults.Small, - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium - ) - .fillMaxWidth() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 68.dp), // padding for fab - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { SpacerMedium() } - - // TODO: remove hints - items(state.hints, key = { it.hashCode() }) { - when (it) { - is PrescriptionScreenHintDemoModeActivated -> - PrescriptionScreenDemoModeActivatedCard(cardPaddingModifier) { - prescriptionViewModel.onCloseHintCard(it) - } - is PrescriptionScreenHintTryDemoMode -> - PrescriptionScreenTryDemoModeCard( - cardPaddingModifier, - onClickAction = { + state.prescriptions.forEachIndexed { index, prescription -> + item(key = "prescription-${prescription.taskId}") { + when (prescription) { + is PrescriptionUseCaseData.Prescription.Synced -> + FullDetailMedication( + prescription, + modifier = CardPaddingModifier, + onClick = { navController.navigate( - MainNavigationScreens.Settings.path( - SettingsScrollTo.DemoMode + MainNavigationScreens.PrescriptionDetail.path( + taskId = prescription.taskId ) ) - }, - onClose = { prescriptionViewModel.onCloseHintCard(it) } + } ) - is PrescriptionScreenHintDefineSecurity -> - PrescriptionScreenDefineSecurityCard( - cardPaddingModifier, - onClickAction = { + + is PrescriptionUseCaseData.Prescription.Scanned -> + LowDetailMedication( + modifier = CardPaddingModifier, + prescription, + onClick = { navController.navigate( - MainNavigationScreens.Settings.path( - SettingsScrollTo.Authentication + MainNavigationScreens.PrescriptionDetail.path( + taskId = prescription.taskId ) ) } ) } } + } +} - item { - RefreshDivider( - modifier = headerPaddingModifier, - onClickRefresh = onClickRefresh +@Preview +@Composable +private fun FullDetailRecipeCardPreview() { + AppTheme { + Column { + FullDetailMedication( + modifier = Modifier, + prescription = + PrescriptionUseCaseData.Prescription.Synced( + "", + organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", + name = "Pantoprazol 40 mg - Medikament mit sehr vielen Namensbestandteilen", + authoredOn = Instant.now(), + isIncomplete = false, + redeemedOn = null, + expiresOn = Instant.now().plus(21, ChronoUnit.DAYS), + acceptUntil = Instant.now().minus(1, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.Other(SyncedTaskData.TaskStatus.InProgress, Instant.now()), + isDirectAssignment = false, + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState() + ), + onClick = {} ) - } - - if (state.prescriptions.isEmpty()) { - item { - NothingToShowNote( - cardPaddingModifier.padding( - top = PaddingDefaults.Small + SpacerMedium() + + FullDetailMedication( + modifier = Modifier, + prescription = + PrescriptionUseCaseData.Prescription.Synced( + organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", + taskId = "", + name = "Pantoprazol 40 mg", + isIncomplete = false, + authoredOn = Instant.now(), + redeemedOn = null, + expiresOn = Instant.now().plus(20, ChronoUnit.DAYS), + acceptUntil = Instant.now().plus(97, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.Other(SyncedTaskData.TaskStatus.Other, Instant.now()), + isDirectAssignment = false, + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState() + ), + onClick = {} + ) + SpacerMedium() + + FullDetailMedication( + modifier = Modifier, + prescription = + PrescriptionUseCaseData.Prescription.Synced( + organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", + taskId = "", + name = "Pantoprazol 40 mg", + isIncomplete = false, + authoredOn = Instant.now(), + redeemedOn = null, + expiresOn = Instant.now(), + acceptUntil = Instant.now().plus(1, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.Other(SyncedTaskData.TaskStatus.Completed, Instant.now()), + isDirectAssignment = false, + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState() + ), + onClick = {} + ) + SpacerMedium() + + FullDetailMedication( + modifier = Modifier, + prescription = + PrescriptionUseCaseData.Prescription.Synced( + organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", + taskId = "12344", + name = "Pantoprazol 40 mg", + authoredOn = Instant.now(), + isIncomplete = false, + redeemedOn = null, + expiresOn = Instant.now().minus(1, ChronoUnit.DAYS), + acceptUntil = Instant.now().minus(1, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.LaterRedeemable( + Instant.now().minus(1, ChronoUnit.DAYS) ), - stringResource(R.string.prescription_overview_info_no_recipes) - ) - } - } else { - itemsIndexed(state.prescriptions) { index, prescription -> - val isFirstSyncedPrescription = - (index == 0 && prescription is PrescriptionUseCaseData.Prescription.Synced) - val titleChanged = ( - index > 0 && - (state.prescriptions[index - 1] as? PrescriptionUseCaseData.Prescription.Synced)?.organization != - (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization + isDirectAssignment = false, + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState( + isPartOfMultiplePrescription = true, + numerator = "1", + denominator = "4", + start = Instant.now() ) + ), + onClick = {} + ) - if (isFirstSyncedPrescription || titleChanged) { - Text( - (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization ?: "", - style = MaterialTheme.typography.h6, - modifier = Modifier - .fillMaxWidth() - .then(headerPaddingModifier) - ) - } - - when (prescription) { - is PrescriptionUseCaseData.Prescription.Synced -> - FullDetailMedication( - prescription, - state.nowInEpochDays, - modifier = cardPaddingModifier, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) - } - ) - - is PrescriptionUseCaseData.Prescription.Scanned -> - LowDetailMedication( - modifier = cardPaddingModifier, - prescription, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) - } - ) - } - } - } - - item { RedeemedHeader(headerPaddingModifier) } - - if (state.redeemedPrescriptions.isEmpty()) { - item { - NothingToShowNote( - cardPaddingModifier.padding( - top = PaddingDefaults.Small + SpacerMedium() + FullDetailMedication( + modifier = Modifier, + prescription = + PrescriptionUseCaseData.Prescription.Synced( + organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", + taskId = "12344", + name = "Medication", + authoredOn = Instant.now(), + isIncomplete = true, + redeemedOn = null, + expiresOn = Instant.now().minus(1, ChronoUnit.DAYS), + acceptUntil = Instant.now().minus(1, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.LaterRedeemable( + Instant.now().minus(1, ChronoUnit.DAYS) ), - stringResource(R.string.prs_not_redeemed_note) - ) - } - } else { - items(state.redeemedPrescriptions) { prescription -> - when (prescription) { - is PrescriptionUseCaseData.Prescription.Scanned -> - LowDetailMedication( - modifier = cardPaddingModifier, - prescription, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) - } - ) - is PrescriptionUseCaseData.Prescription.Synced -> - FullDetailMedication( - prescription, - state.nowInEpochDays, - modifier = cardPaddingModifier, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) - } - ) - } - } - } - } -} - -@Composable -private fun Banner(modifier: Modifier, onClose: () -> Unit) { - Card(modifier.fillMaxWidth()) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.padding(end = 16.dp), AppTheme.colors.neutral400) - Text( - text = stringResource(R.string.main_banner_authentication_text), Modifier.weight(1f), color = AppTheme.colors.neutral700, - style = AppTheme.typography.body2l + isDirectAssignment = false, + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState( + isPartOfMultiplePrescription = false, + numerator = null, + denominator = null, + start = null + ) + ), + onClick = {} ) - Image(Icons.Rounded.Close, null, modifier = Modifier.padding(start = 20.dp).clickable { onClose() }, alpha = 0.3f) } } } @Composable -private fun NothingToShowNote( - modifier: Modifier = Modifier, - text: String -) { - Card( - modifier = modifier, - backgroundColor = AppTheme.colors.neutral100, - contentColor = AppTheme.colors.neutral600, - elevation = 1.dp, - shape = RoundedCornerShape(8.dp) - ) { - Text( - text, - textAlign = TextAlign.Center, - style = AppTheme.typography.body2l, - modifier = Modifier - .padding(16.dp) - .testTag("erx_txt_empty_list") +fun readyPrescriptionStateInfo( + acceptDaysLeft: Long, + expiryDaysLeft: Long +): AnnotatedString? = when { + acceptDaysLeft == ZERO_DAYS_LEFT -> buildAnnotatedString { + appendInlineContent( + id = "warningAmber", + alternateText = stringResource(R.string.prescription_item_warning_amber) ) + append(stringResource(R.string.prescription_item_accept_only_today)) } -} -// TODO remove if https://issuetracker.google.com/issues/162408885 is resolved -// Source: PreUpPostDownNestedScrollConnection is currently internal in compose but we need the same -// behavior for our pull/swipe to refresh layout -@OptIn(ExperimentalMaterialApi::class) -private fun SwipeableState.preUpPostDownNestedScrollConnection(minBound: Float): NestedScrollConnection = - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.toFloat() - return if (delta < 0 && source == NestedScrollSource.Drag) { - performDrag(delta).toOffset() - } else { - Offset.Zero - } - } + expiryDaysLeft == ZERO_DAYS_LEFT -> buildAnnotatedString { + appendInlineContent( + id = "warningAmber", + alternateText = stringResource(R.string.prescription_item_warning_amber) + ) + append(stringResource(R.string.prescription_item_expiration_only_today)) + } - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.Drag) { - performDrag(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } + acceptDaysLeft in ONE_DAY_LEFT..TWO_DAYS_LEFT -> buildAnnotatedString { + appendInlineContent( + id = "warningAmber", + alternateText = stringResource(R.string.prescription_item_warning_amber) + ) + append( + annotatedPluralsResource( + R.plurals.prescription_item_accept_days, + acceptDaysLeft.toInt(), + AnnotatedString(acceptDaysLeft.toString()) + ) + ) + } - override suspend fun onPreFling(available: Velocity): Velocity { - val toFling = Offset(available.x, available.y).toFloat() - return if (toFling > 0 && offset.value > minBound) { - performFling(velocity = toFling) - // since we go to the anchor with tween settling, consume all for the best UX - available - } else { - Velocity.Zero - } - } + expiryDaysLeft in ONE_DAY_LEFT..TWO_DAYS_LEFT -> buildAnnotatedString { + appendInlineContent( + id = "warningAmber", + alternateText = stringResource(R.string.prescription_item_warning_amber) + ) + append( + annotatedPluralsResource( + R.plurals.prescription_item_expiration_days_new, + expiryDaysLeft.toInt(), + AnnotatedString(expiryDaysLeft.toString()) + ) + ) + } - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - performFling(velocity = Offset(available.x, available.y).toFloat()) - return available - } + acceptDaysLeft > TWO_DAYS_LEFT -> annotatedPluralsResource( + R.plurals.prescription_item_accept_days, + acceptDaysLeft.toInt(), + AnnotatedString(acceptDaysLeft.toString()) + ) - private fun Float.toOffset(): Offset = Offset(0f, this) + expiryDaysLeft > TWO_DAYS_LEFT -> annotatedPluralsResource( + R.plurals.prescription_item_expiration_days_new, + expiryDaysLeft.toInt(), + AnnotatedString(expiryDaysLeft.toString()) + ) - private fun Offset.toFloat(): Float = this.y - } + else -> null +} -@OptIn(ExperimentalMaterialApi::class) @Composable -private fun PullRefresh( - state: SwipeableState, - modifier: Modifier = Modifier, - content: @Composable () -> Unit +fun prescriptionStateInfo( + state: SyncedTaskData.SyncedTask.TaskState, + pharmacyName: String? = null, + now: Instant = Instant.now() ) { - val refreshDistance = with(LocalDensity.current) { 80.dp.toPx() } + val warningAmber = mapOf( + "warningAmber" to InlineTextContent( + Placeholder( + width = 0.em, + height = 0.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon( + imageVector = Icons.Rounded.WarningAmber, + modifier = Modifier.padding(end = PaddingDefaults.Tiny), + contentDescription = null, + tint = AppTheme.colors.red600 + ) + } + ) - Box( - modifier = modifier - .testTag("pull2refresh") - .nestedScroll(state.preUpPostDownNestedScrollConnection(-refreshDistance)) - .swipeable( - state = state, - anchors = mapOf( - -refreshDistance to false, - refreshDistance to true + when (state) { + is SyncedTaskData.SyncedTask.LaterRedeemable -> { + Text( + text = dateWithIntroductionString( + R.string.pres_detail_medication_redeemable_on, + state.redeemableOn ), - orientation = Orientation.Vertical, + style = AppTheme.typography.body2l ) - .fillMaxSize() - ) { - content() - - val size = 48.dp - val offset = if (!state.offset.value.isNaN()) state.offset.value else 0.0f - val progress = offset / refreshDistance - - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .wrapContentSize() - .offset { IntOffset(y = offset.roundToInt(), x = 0) } - .alpha(progress) - ) { - Card( - shape = RoundedCornerShape(size / 2), - elevation = 8.dp, - modifier = Modifier - .padding(8.dp) - .size(size) - ) { - if (state.currentValue || state.isAnimationRunning) { - CircularProgressIndicator( - modifier = Modifier.padding(4.dp) - ) - } else { - CircularProgressIndicator( - progress = progress, - modifier = Modifier.padding(4.dp) - ) - } + } + + is SyncedTaskData.SyncedTask.Ready -> { + val expiryDaysLeft = remember { Duration.between(now, state.expiresOn).toDays() } + val acceptDaysLeft = remember { Duration.between(now, state.acceptUntil).toDays() } + + val text = readyPrescriptionStateInfo(acceptDaysLeft, expiryDaysLeft) + + when { + acceptDaysLeft in ZERO_DAYS_LEFT..TWO_DAYS_LEFT || + expiryDaysLeft in ZERO_DAYS_LEFT..TWO_DAYS_LEFT -> + text?.let { + DynamicText( + it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = AppTheme.typography.body2, + color = AppTheme.colors.red600, + inlineContent = warningAmber + ) + } + + acceptDaysLeft > TWO_DAYS_LEFT || expiryDaysLeft > TWO_DAYS_LEFT -> + text?.let { Text(it, style = AppTheme.typography.body2) } + + else -> {} } } - } -} -@Preview -@Composable -private fun FullDetailRecipeCardPreview() { - AppTheme { - FullDetailMedication( - modifier = Modifier, - prescription = - PrescriptionUseCaseData.Prescription.Synced( - "", - organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", - name = "Pantoprazol 40 mg - Medikament mit sehr vielen Namensbestandteilen", - authoredOn = OffsetDateTime.now(), - redeemedOn = null, - expiresOn = LocalDate.now().plusDays(21), - acceptUntil = LocalDate.now().plusDays(-1), - status = PrescriptionUseCaseData.Prescription.Synced.Status.InProgress, - isDirectAssignment = false, - ), - nowInEpochDays = Duration.between( - LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), - LocalDateTime.now() - ).toDays(), - onClick = {} - ) + is SyncedTaskData.SyncedTask.InProgress -> { + val lastModified = remember { LocalDateTime.ofInstant(state.lastModified, ZoneId.systemDefault()) } + val dayDifference = remember { + Duration.between(lastModified, now.atZone(ZoneId.systemDefault()).toLocalDateTime()).toDays() + } + val minDifference = remember { + Duration.between(lastModified, now.atZone(ZoneId.systemDefault()).toLocalDateTime()).toMinutes() + } + val text = when { + minDifference < 5L -> stringResource(R.string.sent_now) + minDifference < 60L -> annotatedStringResource( + R.string.sent_x_min_ago, + minDifference + ).toString() - FullDetailMedication( - modifier = Modifier, - prescription = - PrescriptionUseCaseData.Prescription.Synced( - organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", - taskId = "", - name = "Pantoprazol 40 mg", - authoredOn = OffsetDateTime.now(), - redeemedOn = null, - expiresOn = LocalDate.now().plusDays(20), - acceptUntil = LocalDate.now().plusDays(97), - status = PrescriptionUseCaseData.Prescription.Synced.Status.Unknown, - isDirectAssignment = false - ), - nowInEpochDays = Duration.between( - LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), - LocalDateTime.now() - ).toDays(), - onClick = {} - ) + dayDifference < 0L -> annotatedStringResource( + R.string.sent_on_day, + remember { dateString(lastModified) } + ).toString() - FullDetailMedication( - modifier = Modifier, - prescription = - PrescriptionUseCaseData.Prescription.Synced( - organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", - taskId = "", - name = "Pantoprazol 40 mg", - authoredOn = OffsetDateTime.now(), - redeemedOn = null, - expiresOn = LocalDate.now(), - acceptUntil = LocalDate.now().plusDays(1), - status = PrescriptionUseCaseData.Prescription.Synced.Status.Completed, - isDirectAssignment = false - ), - nowInEpochDays = Duration.between( - LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), - LocalDateTime.now() - ).toDays(), - onClick = {} - ) - } -} + else -> annotatedStringResource( + R.string.sent_on_minute, + remember { timeString(lastModified) } + ).toString() + } -@Composable -fun expiryOrAcceptString( - expiryDate: LocalDate, - acceptDate: LocalDate, - nowInEpochDays: Long -): String { - val expiryDaysLeft = remember { expiryDate.toEpochDay() - nowInEpochDays } - val acceptDaysLeft = remember { acceptDate.toEpochDay() - nowInEpochDays } - - return when { - acceptDaysLeft == 0L -> { - stringResource(id = R.string.prescription_item_accept_only_today) + Text(text, style = AppTheme.typography.body2) } - expiryDaysLeft == 1L -> { - stringResource(id = R.string.prescription_item_expiration_only_today) + + is SyncedTaskData.SyncedTask.Pending -> { + val sentOn = remember { LocalDateTime.ofInstant(state.sentOn, ZoneId.systemDefault()) } + Text( + annotatedStringResource( + R.string.sent_on_to_pharmacy, + phrasedDateString(sentOn), + pharmacyName ?: stringResource(R.string.orders_generic_pharmacy_name) + ).toString(), + style = AppTheme.typography.body2 + ) } - expiryDaysLeft <= 0L -> { - stringResource(id = R.string.prescription_item_expired) + + is SyncedTaskData.SyncedTask.Expired -> { + Text( + dateWithIntroductionString(R.string.pres_detail_medication_expired_on, state.expiredOn), + style = AppTheme.typography.body2 + ) } - else -> - if (acceptDaysLeft > 1L) { - annotatedPluralsResource( - R.plurals.prescription_item_accept_days, - acceptDaysLeft.toInt(), - AnnotatedString(acceptDaysLeft.toString()) - ).toString() - } else { - annotatedPluralsResource( - R.plurals.prescription_item_expiration_days_new, - expiryDaysLeft.toInt(), - AnnotatedString(expiryDaysLeft.toString()) - ).toString() + is SyncedTaskData.SyncedTask.Other -> { + if (state.state == SyncedTaskData.TaskStatus.Completed) { + val completedOn = remember { LocalDateTime.ofInstant(state.lastModified, ZoneId.systemDefault()) } + Text( + text = annotatedStringResource( + R.string.received_on_to_pharmacy, + phrasedDateString(completedOn), + pharmacyName ?: stringResource(R.string.orders_generic_pharmacy_name) + ).toString(), + style = AppTheme.typography.body2l + ) } + } } } @OptIn(ExperimentalMaterialApi::class) @Composable -private fun FullDetailMedication( +fun FullDetailMedication( prescription: PrescriptionUseCaseData.Prescription.Synced, - nowInEpochDays: Long, modifier: Modifier = Modifier, onClick: () -> Unit ) { + val showDirectAssignmentLabel by derivedStateOf { + val isCompleted = + (prescription.state as? SyncedTaskData.SyncedTask.Other)?.state == SyncedTaskData.TaskStatus.Completed + + prescription.isDirectAssignment && !isCompleted + } + Card( - modifier = modifier, - shape = RoundedCornerShape(8.dp), + modifier = modifier.semantics { + prescriptionId = prescription.taskId + } + .testTag(TestTag.Prescriptions.FullDetailPrescription), + shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, color = AppTheme.colors.neutral300), + backgroundColor = AppTheme.colors.neutral050, elevation = 0.dp, onClick = onClick ) { Row(modifier = Modifier.padding(PaddingDefaults.Medium)) { Column(modifier = Modifier.weight(1f)) { - when (prescription.status) { - PrescriptionUseCaseData.Prescription.Synced.Status.Ready -> - ReadyStatusChip() - PrescriptionUseCaseData.Prescription.Synced.Status.InProgress -> - InProgressStatusChip() - PrescriptionUseCaseData.Prescription.Synced.Status.Completed -> - CompletedStatusChip() - PrescriptionUseCaseData.Prescription.Synced.Status.Unknown -> - UnknownStatusChip() + if (prescription.isIncomplete) { + FailureStatusChip() + } else if (showDirectAssignmentLabel) { + DirectAssignmentStatusChip() + } else { + when (prescription.state) { + is SyncedTaskData.SyncedTask.InProgress -> InProgressStatusChip() + is SyncedTaskData.SyncedTask.Pending -> PendingStatusChip() + is SyncedTaskData.SyncedTask.Ready -> ReadyStatusChip() + is SyncedTaskData.SyncedTask.Expired -> ExpiredStatusChip() + is SyncedTaskData.SyncedTask.LaterRedeemable -> LaterRedeemableStatusChip() + + is SyncedTaskData.SyncedTask.Other -> { + when (prescription.state.state) { + SyncedTaskData.TaskStatus.Completed -> CompletedStatusChip() + else -> UnknownStatusChip() + } + } + } } Spacer(Modifier.height(PaddingDefaults.Small + PaddingDefaults.Tiny)) + val medicationName = + prescription.name ?: stringResource(R.string.prescription_medication_default_name) + Text( - prescription.name, - style = MaterialTheme.typography.subtitle1 + modifier = Modifier.testTag(TestTag.Prescriptions.FullDetailPrescriptionName), + text = medicationName, + style = AppTheme.typography.subtitle1, + maxLines = 3, + overflow = TextOverflow.Ellipsis ) - val text = if (prescription.redeemedOn != null) { - val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") - prescription.redeemedOn.toInstant().atZone(ZoneId.systemDefault()) - .toLocalDate().format(dateFormatter) - } else { - if (prescription.isDirectAssignment) { - stringResource(R.string.direct_assignment_will_be_forwardet) - } else { - prescription.expiresOn?.let { expiryDate -> - prescription.acceptUntil?.let { acceptDate -> - expiryOrAcceptString( - expiryDate = expiryDate, - acceptDate = acceptDate, - nowInEpochDays = nowInEpochDays - ) - } + if (!prescription.isDirectAssignment) { + prescriptionStateInfo(prescription.state) + } + + if (prescription.multiplePrescriptionState.isPartOfMultiplePrescription) { + prescription.multiplePrescriptionState.numerator?.let { numerator -> + prescription.multiplePrescriptionState.denominator?.let { denominator -> + SpacerShortMedium() + NumeratorChip(numerator, denominator) } } - } ?: "" - - Text( - text, - style = AppTheme.typography.body2l - ) + } } Icon( - Icons.Filled.KeyboardArrowRight, null, + Icons.Filled.KeyboardArrowRight, + null, tint = AppTheme.colors.neutral400, modifier = Modifier .size(24.dp) @@ -799,17 +768,17 @@ private fun LowDetailRecipeCardPreview() { Modifier, prescription = PrescriptionUseCaseData.Prescription.Scanned( "", - OffsetDateTime.now(), - redeemedOn = OffsetDateTime.now().plusDays(2) + Instant.now(), + redeemedOn = Instant.now().plus(2, ChronoUnit.DAYS) ), - onClick = {}, + onClick = {} ) } } @OptIn(ExperimentalMaterialApi::class) @Composable -private fun LowDetailMedication( +fun LowDetailMedication( modifier: Modifier = Modifier, prescription: PrescriptionUseCaseData.Prescription.Scanned, onClick: () -> Unit @@ -817,12 +786,12 @@ private fun LowDetailMedication( val dateFormatter = remember { DateTimeFormatter.ofPattern("dd.MM.yyyy") } val scannedOn = remember { - prescription.scannedOn.toInstant().atZone(ZoneId.systemDefault()) + prescription.scannedOn.atZone(ZoneId.systemDefault()) .toLocalDate().format(dateFormatter) } val redeemedOn = remember { - prescription.redeemedOn?.toInstant()?.atZone(ZoneId.systemDefault()) + prescription.redeemedOn?.atZone(ZoneId.systemDefault()) ?.toLocalDate()?.format(dateFormatter) } @@ -834,9 +803,10 @@ private fun LowDetailMedication( Card( modifier = modifier, - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, color = AppTheme.colors.neutral300), elevation = 0.dp, + backgroundColor = AppTheme.colors.neutral050, onClick = onClick ) { Row(modifier = Modifier.padding(PaddingDefaults.Medium)) { @@ -846,17 +816,18 @@ private fun LowDetailMedication( ) { Text( stringResource(R.string.prs_low_detail_medication), - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1 ) SpacerTiny() Text( dateText, - style = AppTheme.typography.body2l, + style = AppTheme.typography.body2l ) } Icon( - Icons.Filled.KeyboardArrowRight, null, + Icons.Filled.KeyboardArrowRight, + null, tint = AppTheme.colors.neutral400, modifier = Modifier .size(24.dp) @@ -865,45 +836,3 @@ private fun LowDetailMedication( } } } - -/** - * | Current X Refresh | - */ -@Composable -private fun RefreshDivider(modifier: Modifier = Modifier, onClickRefresh: () -> Unit) { - Row(modifier = modifier) { - Text( - text = stringResource(R.string.prs_divider_refresh_info), - style = MaterialTheme.typography.h6, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Spacer(modifier = Modifier.weight(1.0f)) - TextButton( - onClick = onClickRefresh, - modifier = Modifier - .align(Alignment.CenterVertically) - .testTag("erx_btn_refresh") - ) { - Icon( - Icons.Rounded.Update, null, - modifier = Modifier - .size(24.dp) - .padding(end = 4.dp) - ) - Text( - stringResource(R.string.prs_divider_refresh_action), - style = MaterialTheme.typography.subtitle2 - ) - } - } -} - -@Composable -private fun RedeemedHeader(modifier: Modifier = Modifier) { - Row(modifier = modifier) { - Text( - style = MaterialTheme.typography.h6, - text = stringResource(R.string.prs_divider_redeemed_info), - ) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/PaceKey.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionServiceState.kt similarity index 50% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/PaceKey.kt rename to android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionServiceState.kt index 7f40692e..a68abb22 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/PaceKey.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionServiceState.kt @@ -16,27 +16,23 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.prescription.ui -/** - * Pace Key for TrustedChannel with Session key for encoding and Session key for message authentication - */ -data class PaceKey(val enc: ByteArray, val mac: ByteArray) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as PaceKey +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable - if (!enc.contentEquals(other.enc)) return false - if (!mac.contentEquals(other.mac)) return false +interface PrescriptionServiceState - return true - } +interface PrescriptionServiceErrorState : PrescriptionServiceState - override fun hashCode(): Int { - var result = enc.contentHashCode() - result = 31 * result + mac.contentHashCode() - return result - } +@Stable +sealed interface GenerellErrorState : PrescriptionServiceErrorState { + object NetworkNotAvailable : GenerellErrorState + class ServerCommunicationFailedWhileRefreshing(val code: Int) : GenerellErrorState + object FatalTruststoreState : GenerellErrorState + object NoneEnrolled : GenerellErrorState + object UserNotAuthenticated : GenerellErrorState } + +@Immutable +data class RefreshedState(val nrOfNewPrescriptions: Int) : PrescriptionServiceState diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt index b455c3a0..77e71eda 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt @@ -19,202 +19,62 @@ package de.gematik.ti.erp.app.prescription.ui import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.api.ApiCallException -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.api.map -import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase -import de.gematik.ti.erp.app.common.usecase.HintUseCase -import de.gematik.ti.erp.app.common.usecase.model.CancellableHint -import de.gematik.ti.erp.app.common.usecase.model.Hint -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintDefineSecurity -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintDemoModeActivated -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintTryDemoMode -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken -import de.gematik.ti.erp.app.idp.usecase.RefreshFlowException -import de.gematik.ti.erp.app.mainscreen.ui.PullRefreshState -import de.gematik.ti.erp.app.mainscreen.ui.RefreshEvent +import androidx.lifecycle.ViewModel import de.gematik.ti.erp.app.prescription.ui.model.PrescriptionScreenData import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase -import de.gematik.ti.erp.app.vau.interceptor.VauException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.cancel +import de.gematik.ti.erp.app.profiles.usecase.activeProfile +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.net.SocketTimeoutException -import java.net.UnknownHostException -import java.time.LocalDate -import javax.inject.Inject -@HiltViewModel -class PrescriptionViewModel @Inject constructor( +class PrescriptionViewModel( private val prescriptionUseCase: PrescriptionUseCase, private val profilesUseCase: ProfilesUseCase, - private val settingsUseCase: SettingsUseCase, - private val demoUseCase: DemoUseCase, - private val dispatchProvider: DispatchProvider, - private val hintUseCase: HintUseCase, - private val authenticationUseCase: AuthenticationUseCase -) : BaseViewModel() { + private val dispatchers: DispatchProvider +) : ViewModel() { + private val timeTrigger = MutableSharedFlow() - val defaultState = PrescriptionScreenData.State( - demoUseCase.isDemoModeActive, - emptyList(), - emptyList(), - emptyList(), - 0 - ) - - fun downloadAllAuditEvents(profileName: String) { - prescriptionUseCase.downloadAllAuditEvents(profileName = profileName) - } - - @OptIn(FlowPreview::class) - fun screenState(): Flow { - val prescriptionFlow = combine( - prescriptionUseCase.scannedRecipes(), - prescriptionUseCase.syncedRecipes() - ) { lowDetail, fullDetail -> - (lowDetail + fullDetail) - }.onStart { - emit(emptyList()) - } - - return combine( - demoUseCase.demoModeActive, - prescriptionFlow, - prescriptionUseCase.redeemedPrescriptions(), - settingsUseCase.settings, - hintUseCase.cancelledHints, - ) { demoActive, prescriptions, redeemed, settings, cancelledHints -> - // TODO: remove hints - val hints = mutableListOf() - - if (demoActive && PrescriptionScreenHintDemoModeActivated !in cancelledHints) { - hints += PrescriptionScreenHintDemoModeActivated - } else if (!demoActive && settings.authenticationMethod == SettingsAuthenticationMethod.Unspecified) { - hints += PrescriptionScreenHintDefineSecurity - } - // TODO: combine any authenticated when hints are gone - if (!demoUseCase.demoModeHasBeenSeen && PrescriptionScreenHintTryDemoMode !in cancelledHints && !anyProfileAuthenticated()) { - hints += PrescriptionScreenHintTryDemoMode + init { + viewModelScope.launch { + while (true) { + delay(timeMillis = 1000L * 60L) + timeTrigger.emit(Unit) } - - // TODO: split redeemed & unredeemed - PrescriptionScreenData.State( - showDemoBanner = demoActive, - hints = hints, - prescriptions = prescriptions, - redeemed, - LocalDate.now().toEpochDay() - ) - }.flowOn(dispatchProvider.unconfined()) - } - - private suspend fun anyProfileAuthenticated() = withContext(dispatchProvider.io()) { - profilesUseCase.anyProfileAuthenticated() + } } - suspend fun refreshPrescriptions( - pullRefreshState: PullRefreshState, - isDemoModeActive: Boolean, - onShowSecureHardwarePrompt: suspend () -> Unit, - onShowCardWall: suspend (canAvailable: Boolean) -> Unit, - onRefresh: suspend (event: RefreshEvent) -> Unit - ) { - val profileName = profilesUseCase.activeProfileName().flowOn(dispatchProvider.io()).first() - - Timber.d("Refreshing prescriptions for $profileName") - - val result = withContext(dispatchProvider.io()) { prescriptionUseCase.downloadTasks(profileName) } - .map { nrOfNewPrescriptions -> - if (!isDemoModeActive) { - prescriptionUseCase.downloadCommunications(profileName).map { - downloadAllAuditEvents(profileName) - Result.Success(nrOfNewPrescriptions) - } - } else { - Result.Success(nrOfNewPrescriptions) - } + @OptIn(ExperimentalCoroutinesApi::class) + fun screenState(): Flow = + profilesUseCase.profiles.map { it.activeProfile() }.flatMapLatest { activeProfile -> + val prescriptionFlow = combine( + prescriptionUseCase.scannedActiveRecipes(activeProfile.id), + timeTrigger + .onStart { emit(Unit) } + .flatMapLatest { prescriptionUseCase.syncedActiveRecipes(activeProfile.id) } + .distinctUntilChanged() + ) { lowDetail, fullDetail -> + (lowDetail + fullDetail) } - when (result) { - is Result.Error -> { - (result.exception.cause as? CancellationException)?.let { - return - } - - (result.exception.cause as? RefreshFlowException)?.let { // Hint: We are now in unauthorized state - if (it.userActionRequired) { - if (it.ssoToken is SingleSignOnToken.AlternateAuthenticationWithoutToken) { - onShowSecureHardwarePrompt() - } else { - val canAvailable = isCanAvailable() - onShowCardWall(canAvailable) - } - } - return - } - - when (result.exception.cause?.cause) { - is SocketTimeoutException, - is UnknownHostException -> { - onRefresh(RefreshEvent.NetworkNotAvailable) - return - } - } - - (result.exception.cause as? ApiCallException)?.let { - onRefresh( - RefreshEvent.ServerCommunicationFailedWhileRefreshing( - it.response.code() - ) - ) - return - } - - (result.exception.cause as? VauException)?.let { - onRefresh(RefreshEvent.FatalTruststoreState) - return - } + combine( + prescriptionFlow, + prescriptionUseCase.redeemedPrescriptions(activeProfile.id) + ) { prescriptions, redeemed -> + // TODO: split redeemed & unredeemed + PrescriptionScreenData.State( + prescriptions = prescriptions, + redeemedPrescriptions = redeemed + ) } - is Result.Success -> { - if (pullRefreshState != PullRefreshState.HasValidToken && !isDemoModeActive) { - onRefresh(RefreshEvent.NewPrescriptionsEvent(result.data)) - } - } - } - } - - fun onCloseHintCard(hint: CancellableHint) { - hintUseCase.cancelHint(hint) - } - - fun onAlternateAuthentication() { - viewModelScope.launch { - authenticationUseCase.authenticateWithSecureElement() - .catch { - Timber.e(it) - cancel("just because") - } - .collect() - } - } - - suspend fun isCanAvailable() = authenticationUseCase.isCanAvailable() + }.distinctUntilChanged().flowOn(dispatchers.Default) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/RefreshPrescriptionsController.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/RefreshPrescriptionsController.kt new file mode 100644 index 00000000..76f9fb5d --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/RefreshPrescriptionsController.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.cardwall.mini.ui.Authenticator +import de.gematik.ti.erp.app.cardwall.mini.ui.NoneEnrolledException +import de.gematik.ti.erp.app.cardwall.mini.ui.PromptAuthenticator +import de.gematik.ti.erp.app.cardwall.mini.ui.UserNotAuthenticatedException +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.idp.usecase.IDPConfigException +import de.gematik.ti.erp.app.idp.usecase.RefreshFlowException +import de.gematik.ti.erp.app.mainscreen.ui.MainScreenViewModel +import de.gematik.ti.erp.app.prescription.usecase.RefreshPrescriptionUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.vau.interceptor.VauException +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retry +import org.kodein.di.compose.rememberInstance +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +@Stable +class RefreshPrescriptionsController( + private val refreshPrescriptionUseCase: RefreshPrescriptionUseCase, + private val mainScreenViewModel: MainScreenViewModel, + private val authenticator: Authenticator +) { + + val isRefreshing + @Composable + get() = refreshPrescriptionUseCase.refreshInProgress.collectAsState() + + suspend fun refresh( + profileId: ProfileIdentifier, + isUserAction: Boolean, + onUserNotAuthenticated: () -> Unit, + onShowCardWall: () -> Unit + ) { + val finalState = refreshFlow( + profileId = profileId, + isUserAction = isUserAction + ).cancellable().first() + + when (finalState) { + GenerellErrorState.NoneEnrolled -> { + onShowCardWall() + } + GenerellErrorState.UserNotAuthenticated -> { + onUserNotAuthenticated() + } + else -> { + mainScreenViewModel.onRefresh(finalState) + } + } + } + + private fun refreshFlow( + profileId: ProfileIdentifier, + isUserAction: Boolean + ): Flow = + refreshPrescriptionUseCase.downloadFlow(profileId) + .map { + RefreshedState(it) as PrescriptionServiceState + } + .retryWithAuthenticator( + isUserAction = isUserAction, + authenticate = authenticator.authenticateForPrescriptions(profileId) + ) + .catchAndTransformRemoteExceptions() + .flowOn(Dispatchers.IO) +} + +@Composable +fun rememberRefreshPrescriptionsController(mainScreenViewModel: MainScreenViewModel): RefreshPrescriptionsController { + val refreshPrescriptionUseCase by rememberInstance() + val authenticator = LocalAuthenticator.current + + return remember { + RefreshPrescriptionsController( + refreshPrescriptionUseCase = refreshPrescriptionUseCase, + mainScreenViewModel = mainScreenViewModel, + authenticator = authenticator + ) + } +} + +fun Flow.retryWithAuthenticator( + isUserAction: Boolean, + authenticate: Flow +) = + retry(1) { throwable -> + Napier.d("Retry with authenticator", throwable) + + when { + !isUserAction -> + throw CancellationException("Authentication cancelled due `isUserAction = false`") + (throwable.cause as? RefreshFlowException)?.userActionRequired == true -> { + authenticate + .first() + .let { + when (it) { + PromptAuthenticator.AuthResult.Authenticated -> true + PromptAuthenticator.AuthResult.Cancelled -> + throw CancellationException("Authentication dialog cancelled") + PromptAuthenticator.AuthResult.NoneEnrolled -> + throw NoneEnrolledException() + PromptAuthenticator.AuthResult.UserNotAuthenticated -> + throw UserNotAuthenticatedException() + } + } + } + else -> false + } + } + +fun Flow.catchAndTransformRemoteExceptions() = + catch { throwable -> + Napier.d("Try to transform exception", throwable) + + throwable.walkCause()?.also { emit(it) } ?: throw throwable + } + +private fun Throwable.walkCause(): GenerellErrorState? = + cause?.walkCause() ?: transformException() + +private fun Throwable.transformException(): GenerellErrorState? = + when (this) { + is UserNotAuthenticatedException -> + GenerellErrorState.UserNotAuthenticated + is NoneEnrolledException -> + GenerellErrorState.NoneEnrolled + is VauException -> + GenerellErrorState.FatalTruststoreState + is IDPConfigException -> // TODO use other state + GenerellErrorState.FatalTruststoreState + is SocketTimeoutException, + is UnknownHostException -> + GenerellErrorState.NetworkNotAvailable + is ApiCallException -> + GenerellErrorState.ServerCommunicationFailedWhileRefreshing( + this.response.code() + ) + else -> null + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt index 99bb45e6..5f7b1c87 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionsViewModel.kt @@ -20,15 +20,15 @@ package de.gematik.ti.erp.app.prescription.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider import de.gematik.ti.erp.app.prescription.ui.model.ScanScreenData import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -38,8 +38,7 @@ import kotlinx.coroutines.flow.toCollection import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import java.time.OffsetDateTime -import javax.inject.Inject +import java.time.Instant private data class ScanWorkflow( val info: ScanScreenData.Info? = null, @@ -71,13 +70,13 @@ private data class ScanWorkflow( } @OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class ScanPrescriptionViewModel @Inject constructor( +class ScanPrescriptionViewModel( private val prescriptionUseCase: PrescriptionUseCase, + private val profilesUseCase: ProfilesUseCase, val scanner: TwoDCodeScanner, val processor: TwoDCodeProcessor, private val validator: TwoDCodeValidator, - private val dispatchProvider: DispatchProvider + private val dispatchers: DispatchProvider ) : ViewModel() { private val scannedCodes = MutableStateFlow(listOf()) @@ -85,7 +84,7 @@ class ScanPrescriptionViewModel @Inject constructor( private set private val emptyScanWorkflow = ScanWorkflow( - code = ScannedCode("", OffsetDateTime.now()), + code = ScannedCode("", Instant.now()), coordinates = FloatArray(0), state = ScanScreenData.ScanState.Final ) @@ -95,7 +94,7 @@ class ScanPrescriptionViewModel @Inject constructor( validCode.urls.mapNotNull { url -> TwoDCodeValidator.taskPattern.matchEntire(url)?.groupValues?.get(1) } - } + prescriptionUseCase.getAllTasksWithTaskIdOnly() + } + prescriptionUseCase.getAllTasksWithTaskIdOnly().first() } fun screenState() = scannedCodes.map { codes -> @@ -114,7 +113,7 @@ class ScanPrescriptionViewModel @Inject constructor( Pair( batch.averageScanTime, ScanWorkflow( - code = ScannedCode(json, OffsetDateTime.now()), + code = ScannedCode(json, Instant.now()), coordinates = coords ) ) @@ -181,17 +180,16 @@ class ScanPrescriptionViewModel @Inject constructor( ScanScreenData.OverlayState( area = if (it != emptyScanWorkflow) it.coordinates else null, state = it.state ?: ScanScreenData.ScanState.Hold, - info = it.info ?: ScanScreenData.Info.Focus, + info = it.info ?: ScanScreenData.Info.Focus ) ) } - }.flowOn(dispatchProvider.default()) + }.flowOn(dispatchers.Default) private fun validateScannedCode(scannedCode: ScannedCode): ValidScannedCode? = validator.validate(scannedCode) suspend fun addScannedCode(validCode: ValidScannedCode): Boolean { - val existingTaskIds = existingTaskIds.take(1).toCollection(mutableListOf()).first() val uniqueUrls = validCode.urls.filter { url -> @@ -208,9 +206,11 @@ class ScanPrescriptionViewModel @Inject constructor( } fun saveToDatabase() { - viewModelScope.launch(dispatchProvider.io()) { - prescriptionUseCase.mapScannedCodeToTask(scannedCodes.value) - scannedCodes.value = listOf() + viewModelScope.launch { + prescriptionUseCase.saveScannedCodes( + profilesUseCase.activeProfile.first().id, + scannedCodes.value + ) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt index c6c0351b..44712e5a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/ScanScreenComponent.kt @@ -74,7 +74,6 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.IconToggleButton -import androidx.compose.material.MaterialTheme import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Surface @@ -122,26 +121,23 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.systemBarsPadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.LocalAnalytics import de.gematik.ti.erp.app.prescription.ui.model.ScanScreenData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AlertDialog import de.gematik.ti.erp.app.utils.compose.BottomSheetAction -import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog import de.gematik.ti.erp.app.utils.compose.Spacer4 import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource import de.gematik.ti.erp.app.utils.compose.annotatedStringBold -import de.gematik.ti.erp.app.utils.compose.testId import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale @@ -151,36 +147,22 @@ import java.util.concurrent.Executors @Composable fun ScanScreen( mainNavController: NavController, - scanViewModel: ScanPrescriptionViewModel = hiltViewModel() + scanViewModel: ScanPrescriptionViewModel ) { val context = LocalContext.current - var shouldShowEduDialog by rememberSaveable { mutableStateOf(false) } - var eduDialogAccepted by rememberSaveable { mutableStateOf(false) } var camPermissionGranted by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - shouldShowEduDialog = - context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED - if (!shouldShowEduDialog) { - eduDialogAccepted = true - } - } - val camPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { camPermissionGranted = it } - if (shouldShowEduDialog && !eduDialogAccepted) { - EducationalDialog { - eduDialogAccepted = true - } - } - - LaunchedEffect(eduDialogAccepted) { - if (eduDialogAccepted) { + LaunchedEffect(Unit) { + if (context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { camPermissionLauncher.launch(Manifest.permission.CAMERA) + } else { + camPermissionGranted = true } } @@ -205,12 +187,14 @@ fun ScanScreen( ) } + val tracker = LocalAnalytics.current ModalBottomSheetLayout( sheetState = sheetState, sheetContent = { SheetContent( onClickSave = { scanViewModel.saveToDatabase() + tracker.trackSaveScannedPrescriptions() mainNavController.popBackStack() } ) @@ -310,7 +294,7 @@ private fun AccessDenied() { modifier = Modifier .fillMaxSize() .systemBarsPadding() - .testTag("camera/disallowed"), + .testTag("camera/disallowed") ) { TopBar( flashEnabled = false, @@ -323,18 +307,19 @@ private fun AccessDenied() { horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - Icons.Rounded.ErrorOutline, null, + Icons.Rounded.ErrorOutline, + null, modifier = Modifier.size(48.dp) ) Text( stringResource(R.string.cam_access_denied_headline), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) Text( stringResource(R.string.cam_access_denied_description), - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) @@ -374,24 +359,6 @@ private fun HapticAndAudibleFeedback(scanVM: ScanPrescriptionViewModel = viewMod } } -@Composable -private fun EducationalDialog( - onContinue: () -> Unit, -) { - var dialogOpen by remember { mutableStateOf(true) } - - if (dialogOpen) { - CommonAlertDialog( - header = stringResource(R.string.cam_edu_headline), - info = stringResource(R.string.cam_edu_description), - cancelText = stringResource(R.string.cancel), - actionText = stringResource(R.string.cam_edu_accept), - onCancel = { dialogOpen = false }, - onClickAction = onContinue - ) - } -} - @Composable private fun SaveDialog( onDismissRequest: () -> Unit, @@ -407,13 +374,13 @@ private fun SaveDialog( buttons = { TextButton( onClick = { onDismissRequest() }, - modifier = Modifier.testId("camera/saveDialog/dismissDialogButton") + modifier = Modifier.testTag("camera/saveDialog/dismissDialogButton") ) { Text(stringResource(R.string.cam_cancel_resume).uppercase(Locale.getDefault())) } - TextButton(onClick = { onCancel() }, modifier = Modifier.testId("camera/saveDialog/saveButton")) { - Text(stringResource(R.string.cam_cancel_ok).uppercase(Locale.getDefault())) - } + TextButton(onClick = { onCancel() }, modifier = Modifier.testTag("camera/saveDialog/saveButton")) { + Text(stringResource(R.string.cam_cancel_ok).uppercase(Locale.getDefault())) + } } ) @@ -428,6 +395,7 @@ private fun beep(toneGenerator: ToneGenerator, pattern: ScanScreenData.Vibration ToneGenerator.TONE_PROP_NACK, 1000 ) + else -> {} } } @@ -495,7 +463,7 @@ private fun ActionBarButton( annotatedPluralsResource( R.plurals.cam_next_with, data.totalNrOfPrescriptions, - AnnotatedString(data.totalNrOfPrescriptions.toString()), + AnnotatedString(data.totalNrOfPrescriptions.toString()) ) ) } @@ -503,7 +471,7 @@ private fun ActionBarButton( @Composable private fun InfoCard( info: ScanScreenData.Info, - modifier: Modifier, + modifier: Modifier ) = Card( backgroundColor = Color.Black.copy(alpha = 0.6f), @@ -534,14 +502,14 @@ private fun InfoCard( ScanScreenData.Info.Focus -> Text( scanning, textAlign = TextAlign.Center, - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) ScanScreenData.Info.ErrorNotValid -> InfoError(invalid) ScanScreenData.Info.ErrorDuplicated -> InfoError(duplicated) is ScanScreenData.Info.Scanned -> Text( detected, textAlign = TextAlign.Center, - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) } @@ -568,7 +536,7 @@ private fun InfoError(text: String) { Text( text, textAlign = TextAlign.Center, - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) } } @@ -627,7 +595,10 @@ private fun CameraView( cameraProvider.unbindAll() camera = cameraProvider.bindToLifecycle( - lifecycleOwner, cameraSelector, preview, imageAnalysis + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis ) }, ContextCompat.getMainExecutor(context) @@ -674,7 +645,7 @@ private fun CameraView( @Composable private fun TopBar( flashEnabled: Boolean, - onFlashClick: (Boolean) -> Unit, + onFlashClick: (Boolean) -> Unit ) { val backPressDispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher @@ -690,7 +661,7 @@ private fun TopBar( IconButton( onClick = { backPressDispatcher.onBackPressed() }, modifier = Modifier - .testId("camera/closeButton") + .testTag("camera/closeButton") .semantics { contentDescription = accCancel } ) { Icon(Icons.Rounded.Close, null, modifier = Modifier.size(24.dp)) @@ -702,7 +673,7 @@ private fun TopBar( checked = flashEnabled, onCheckedChange = onFlashClick, modifier = Modifier - .testId("camera/flashToggle") + .testTag("camera/flashToggle") .semantics { contentDescription = accTorch } ) { val ic = if (flashEnabled) { @@ -722,7 +693,7 @@ private fun ScanOverlay( flashEnabled: Boolean, onFlashClick: (Boolean) -> Unit, modifier: Modifier = Modifier, - scanVM: ScanPrescriptionViewModel = viewModel(), + scanVM: ScanPrescriptionViewModel = viewModel() ) { var points by remember { mutableStateOf(FloatArray(8)) } @@ -780,7 +751,7 @@ private fun ScanOverlay( style = Stroke( width = 4.dp.toPx(), pathEffect = PathEffect.cornerPathEffect(4.dp.toPx()) - ), + ) ) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/StatusChip.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/StatusChip.kt index 6c0cbf77..b581b425 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/StatusChip.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/StatusChip.kt @@ -16,18 +16,26 @@ * */ +@file:Suppress("TooManyFunctions") + package de.gematik.ti.erp.app.prescription.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.EventBusy +import androidx.compose.material.icons.rounded.Today +import androidx.compose.material.icons.rounded.WarningAmber import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,17 +43,21 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource @Composable fun StatusChip( text: String, textColor: Color, backgroundColor: Color, + modifier: Modifier = Modifier, icon: (@Composable () -> Unit)? = null ) { val shape = RoundedCornerShape(8.dp) @@ -53,10 +65,11 @@ fun StatusChip( Modifier .background(backgroundColor, shape) .clip(shape) + .then(modifier) .padding(vertical = 6.dp, horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text(text, style = MaterialTheme.typography.subtitle2, color = textColor) + Text(text, style = AppTheme.typography.subtitle2, color = textColor) icon?.let { SpacerSmall() it() @@ -70,13 +83,15 @@ fun StatusChip( icon: ImageVector, textColor: Color, backgroundColor: Color, - iconColor: Color + iconColor: Color, + modifier: Modifier = Modifier ) = StatusChip( text = text, icon = { Icon(icon, tint = iconColor, contentDescription = null) }, textColor = textColor, - backgroundColor = backgroundColor + backgroundColor = backgroundColor, + modifier = modifier ) @Preview @@ -103,6 +118,21 @@ fun ReadyStatusChip() = iconColor = AppTheme.colors.green500 ) +@Composable +fun PendingStatusChip() = + StatusChip( + text = stringResource(R.string.prescription_status_pending), + icon = { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = AppTheme.colors.neutral500, + strokeWidth = 2.dp + ) + }, + textColor = AppTheme.colors.neutral600, + backgroundColor = AppTheme.colors.neutral100 + ) + @Composable fun InProgressStatusChip() = StatusChip( @@ -113,6 +143,16 @@ fun InProgressStatusChip() = iconColor = AppTheme.colors.yellow500 ) +@Composable +fun FailureStatusChip() = + StatusChip( + text = stringResource(R.string.prescription_status_failure), + icon = Icons.Rounded.WarningAmber, + textColor = AppTheme.colors.red900, + backgroundColor = AppTheme.colors.red100, + iconColor = AppTheme.colors.red500 + ) + @Composable fun CompletedStatusChip() = StatusChip( @@ -123,10 +163,127 @@ fun CompletedStatusChip() = iconColor = AppTheme.colors.neutral500 ) +@Composable +fun ExpiredStatusChip() = + StatusChip( + text = stringResource(R.string.prescription_status_expired), + icon = Icons.Rounded.EventBusy, + textColor = AppTheme.colors.neutral600, + backgroundColor = AppTheme.colors.neutral100, + iconColor = AppTheme.colors.neutral500 + ) + +@Composable +fun LaterRedeemableStatusChip() = + StatusChip( + text = stringResource(R.string.prescription_status_later_redeemable), + icon = Icons.Rounded.Today, + textColor = AppTheme.colors.yellow700, + backgroundColor = AppTheme.colors.yellow100, + iconColor = AppTheme.colors.yellow500 + ) + +@Composable +fun NumeratorChip(numerator: String, denominator: String) { + val text = + annotatedStringResource( + R.string.multiple_prescription_numbering, + numerator, + denominator + ).toString() + + val shape = RoundedCornerShape(8.dp) + Row( + modifier = Modifier + .background(AppTheme.colors.neutral100, shape) + .clip(shape) + .padding(vertical = PaddingDefaults.ShortMedium / 2, horizontal = PaddingDefaults.ShortMedium), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, style = AppTheme.typography.subtitle2, color = AppTheme.colors.neutral600) + } +} + @Composable fun UnknownStatusChip() = StatusChip( text = stringResource(R.string.prescription_status_unknown), textColor = AppTheme.colors.neutral600, - backgroundColor = AppTheme.colors.neutral100, + backgroundColor = AppTheme.colors.neutral100 + ) + +@Composable +fun DirectAssignmentStatusChip() = + StatusChip( + text = stringResource(R.string.prescription_status_direct_assignment), + textColor = AppTheme.colors.neutral600, + backgroundColor = AppTheme.colors.neutral100 + ) + +@Composable +fun DirectAssignmentChip( + modifier: Modifier = Modifier, + onClick: () -> Unit +) = + StatusChip( + modifier = modifier + .clickable(role = Role.Button) { + onClick() + }, + text = stringResource(R.string.prescription_detail_direct_assignment_chip), + icon = Icons.Outlined.Info, + textColor = AppTheme.colors.primary900, + backgroundColor = AppTheme.colors.primary100, + iconColor = AppTheme.colors.primary600 + ) + +@Composable +fun ScannedChip( + modifier: Modifier = Modifier, + onClick: () -> Unit +) = + StatusChip( + modifier = modifier + .clickable(role = Role.Button) { + onClick() + }, + text = stringResource(R.string.prescription_detail_scanned_chip), + icon = Icons.Outlined.Info, + textColor = AppTheme.colors.primary900, + backgroundColor = AppTheme.colors.primary100, + iconColor = AppTheme.colors.primary600 + ) + +@Composable +fun SubstitutionAllowedChip( + modifier: Modifier = Modifier, + onClick: () -> Unit +) = + StatusChip( + modifier = modifier + .clickable(role = Role.Button) { + onClick() + }, + text = stringResource(R.string.prescription_detail_aut_idem_chip), + icon = Icons.Outlined.Info, + textColor = AppTheme.colors.primary900, + backgroundColor = AppTheme.colors.primary100, + iconColor = AppTheme.colors.primary600 + ) + +@Composable +fun FailureDetailsStatusChip( + modifier: Modifier = Modifier, + onClick: () -> Unit +) = + StatusChip( + modifier = modifier + .clickable(role = Role.Button) { + onClick() + }, + text = stringResource(R.string.prescription_status_failure), + icon = Icons.Outlined.Info, + textColor = AppTheme.colors.red900, + backgroundColor = AppTheme.colors.red100, + iconColor = AppTheme.colors.red500 ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeProcessor.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeProcessor.kt index b093ee91..6ac06d6f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeProcessor.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeProcessor.kt @@ -23,9 +23,8 @@ import android.graphics.Point import android.graphics.Rect import android.util.Size import androidx.core.graphics.minus -import com.google.mlkit.vision.barcode.Barcode -import timber.log.Timber -import javax.inject.Inject +import com.google.mlkit.vision.barcode.common.Barcode +import io.github.aakira.napier.Napier import kotlin.math.absoluteValue import kotlin.math.max import kotlin.math.min @@ -78,7 +77,7 @@ data class Metrics( private class FilteredDMCode( var cornerPoints: Array, var boundingBox: Rect, - var value: String, + var value: String ) private fun Barcode.decodeValueToString(): String? = @@ -87,7 +86,7 @@ private fun Barcode.decodeValueToString(): String? = it.code in (32..126) } -class TwoDCodeProcessor @Inject constructor() { +class TwoDCodeProcessor { private fun Rect.center() = Point(this.centerX(), this.centerY()) private fun Size.center() = Point(this.width / 2, this.height / 2) @@ -165,7 +164,7 @@ class TwoDCodeProcessor @Inject constructor() { gluedCodeMatch } else -> { - Timber.d("Moved!!") + Napier.d("Moved!!") // moved; find code nearest to center matrixCodes diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeScanner.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeScanner.kt index 2c843e59..8bcac399 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeScanner.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeScanner.kt @@ -26,21 +26,19 @@ import androidx.camera.core.ImageProxy import androidx.camera.core.ExperimentalGetImage import com.google.mlkit.common.MlKit import com.google.mlkit.common.sdkinternal.MlKitContext -import com.google.mlkit.vision.barcode.Barcode import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow -import timber.log.Timber -import javax.inject.Inject +import io.github.aakira.napier.Napier private const val DEFAULT_SCAN_TIME = 250L -class TwoDCodeScanner @Inject constructor( - @ApplicationContext +class TwoDCodeScanner( + private val context: Context ) : ImageAnalysis.Analyzer { data class Batch( @@ -51,7 +49,9 @@ class TwoDCodeScanner @Inject constructor( ) var batch: MutableSharedFlow = MutableSharedFlow( - replay = 0, extraBufferCapacity = 3, onBufferOverflow = BufferOverflow.DROP_OLDEST + replay = 0, + extraBufferCapacity = 3, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) private set @@ -103,7 +103,7 @@ class TwoDCodeScanner @Inject constructor( } .addOnCompleteListener { imageProxy.close() } } catch (e: Exception) { - Timber.d(e) + Napier.d("2D code processing error", e) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidator.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidator.kt index 33b2b694..0d2d53cb 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidator.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidator.kt @@ -18,15 +18,15 @@ package de.gematik.ti.erp.app.prescription.ui -import com.squareup.moshi.JsonClass -import com.squareup.moshi.Moshi -import timber.log.Timber -import java.time.OffsetDateTime -import javax.inject.Inject +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import io.github.aakira.napier.Napier +import java.time.Instant data class ScannedCode( val json: String, - val scannedOn: OffsetDateTime + val scannedOn: Instant ) data class ValidScannedCode( @@ -34,7 +34,7 @@ data class ValidScannedCode( val urls: List ) -@JsonClass(generateAdapter = true) +@Serializable data class Tasks( val urls: MutableList ) @@ -43,13 +43,10 @@ data class Tasks( * The [TwoDCodeValidator] validates a [ScannedCode] and returns, if the containing json is valid, * a [ValidScannedCode] or otherwise null. */ -class TwoDCodeValidator @Inject constructor( - moshi: Moshi -) { - private val adapter = moshi.adapter(Tasks::class.java) +class TwoDCodeValidator { fun validate(code: ScannedCode): ValidScannedCode? { try { - adapter.fromJson(code.json)?.let { bundle -> + Json.decodeFromString(code.json).let { bundle -> val urls = bundle.urls .takeIf { it.size in MIN_PRESCRIPTIONS..MAX_PRESCRIPTIONS } ?.takeIf { @@ -63,7 +60,7 @@ class TwoDCodeValidator @Inject constructor( } } } catch (e: Exception) { - Timber.d(e, "Couldn't parse data matrix content") + Napier.d("Couldn't parse data matrix content", e) } return null } @@ -73,6 +70,10 @@ class TwoDCodeValidator @Inject constructor( const val MIN_PRESCRIPTIONS = 1 // see gemSpec_FD_eRp A_19019 & A_19021 + + val taskIdPattern = "([A-Za-z0-9\\-.]{1,64})".toRegex() + val accessCodePattern = "([0-9a-f]{64})".toRegex() + val taskPattern = ( "Task/([A-Za-z0-9\\-\\.]{1,64})/\\\$accept\\?ac=([0-9a-f]{64})" ).toRegex() diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt index 8af500b1..4a6b265b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt @@ -19,16 +19,88 @@ package de.gematik.ti.erp.app.prescription.ui.model import androidx.compose.runtime.Immutable -import de.gematik.ti.erp.app.common.usecase.model.Hint +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.prescription.usecase.model.PrescriptionUseCaseData +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData object PrescriptionScreenData { + enum class EmptyActiveScreenState { + LoggedIn, + LoggedOutWithoutTokenBiometrics, + LoggedOutWithoutToken, + LoggedOut, + NeverConnected, + NotEmpty + } + + enum class EmptyArchiveScreenState { + NeverConnected, + NothingArchived, + NotEmpty + } + + private fun ProfilesUseCaseData.Profile.neverConnected() = ssoTokenScope == null && lastAuthenticated == null + + private fun ProfilesUseCaseData.Profile.ssoTokenSetAndConnected() = + ssoTokenScope?.token != null && ssoTokenScope.token?.isValid() == true + + private fun ProfilesUseCaseData.Profile.ssoTokenSetAndDisconnected() = + ssoTokenScope != null && ssoTokenScope.token?.isValid() == false || + lastAuthenticated != null + + private fun ProfilesUseCaseData.Profile.ssoTokenNotSet() = + when (ssoTokenScope) { + is IdpData.ExternalAuthenticationToken, + is IdpData.AlternateAuthenticationToken, + is IdpData.AlternateAuthenticationWithoutToken, + is IdpData.DefaultToken -> ssoTokenScope.token == null + null -> true + } + + private fun ProfilesUseCaseData.Profile.ssoTokenWithoutScope() = + when (ssoTokenScope) { + is IdpData.AlternateAuthenticationWithoutToken -> true + else -> false + } + @Immutable data class State( - val showDemoBanner: Boolean, - val hints: List, val prescriptions: List, - val redeemedPrescriptions: List, - val nowInEpochDays: Long - ) + val redeemedPrescriptions: List + ) { + @Stable + fun emptyActiveScreen(profile: ProfilesUseCaseData.Profile): EmptyActiveScreenState { + val noPrescriptions = prescriptions.isEmpty() + return if (noPrescriptions) { + when { + profile.neverConnected() -> + EmptyActiveScreenState.NeverConnected + profile.ssoTokenWithoutScope() -> + EmptyActiveScreenState.LoggedOutWithoutTokenBiometrics + profile.ssoTokenNotSet() -> + EmptyActiveScreenState.LoggedOutWithoutToken + profile.ssoTokenSetAndConnected() -> + EmptyActiveScreenState.LoggedIn + profile.ssoTokenSetAndDisconnected() -> + EmptyActiveScreenState.LoggedOut + else -> + EmptyActiveScreenState.NotEmpty + } + } else { + EmptyActiveScreenState.NotEmpty + } + } + + @Stable + fun emptyArchiveScreen(profile: ProfilesUseCaseData.Profile): EmptyArchiveScreenState = + when { + redeemedPrescriptions.isEmpty() && profile.neverConnected() -> + EmptyArchiveScreenState.NeverConnected + redeemedPrescriptions.isEmpty() -> + EmptyArchiveScreenState.NothingArchived + else -> + EmptyArchiveScreenState.NotEmpty + } + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt index a2877f44..a59f5e0b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/model/ScanScreenData.kt @@ -54,7 +54,7 @@ object ScanScreenData { data class OverlayState( val area: FloatArray?, val state: ScanState, - val info: Info, + val info: Info ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCase.kt index 08a4dac7..19cdb3b8 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCase.kt @@ -18,61 +18,60 @@ package de.gematik.ti.erp.app.prescription.usecase -import com.google.zxing.BarcodeFormat -import com.google.zxing.common.BitMatrix -import com.google.zxing.datamatrix.DataMatrixWriter -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.db.entities.TaskStatus -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.prescription.detail.ui.model.PrescriptionData +import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository +import de.gematik.ti.erp.app.prescription.ui.TwoDCodeValidator import de.gematik.ti.erp.app.prescription.ui.ValidScannedCode +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData import de.gematik.ti.erp.app.prescription.usecase.model.PrescriptionUseCaseData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import org.json.JSONArray -import org.json.JSONObject -import java.time.OffsetDateTime - -// gemSpec_FD_eRp: A_21267 Prozessparameter - Berechtigungen für Nutzer -const val DIRECT_ASSIGNMENT_INDICATOR = "169" // direct assignment taskID starts with 169 - -interface PrescriptionUseCase { - - fun tasks(): Flow> - - /** - * With the backend synchronized tasks. - */ - fun syncedTasks(): Flow> - - /** - * Scanned codes from the offline e-prescription paper. - */ - fun scannedTasks(): Flow> - - /** - * Tasks grouped by timestamp and organization (e.g. doctor). - * Mapped to [PrescriptionUseCaseData.Prescription.Synced]. - */ - fun syncedRecipes(): Flow> = - syncedTasks().map { tasks -> - tasks.filter { it.isRedeemable() } - .sortedByDescending { it.authoredOn } - .groupBy { it.organization } +import kotlinx.coroutines.flow.transformLatest +import io.github.aakira.napier.Napier +import java.time.Instant + +class PrescriptionUseCase( + private val repository: PrescriptionRepository, + private val dispatchers: DispatchProvider +) { + + fun syncedActiveRecipes( + profileId: ProfileIdentifier, + now: Instant = Instant.now() + ): Flow> = + syncedTasks(profileId).map { tasks -> + tasks.filter { it.isActive(now) } + .sortedWith(compareBy { it.expiresOn }.thenBy { it.authoredOn }) + .groupBy { it.practitioner.name ?: it.organization.name } .flatMap { (_, tasks) -> tasks.map { PrescriptionUseCaseData.Prescription.Synced( taskId = it.taskId, - name = it.medicationText ?: "", - organization = it.organization ?: "", - authoredOn = requireNotNull(it.authoredOn), + name = it.medicationName(), + isIncomplete = it.isIncomplete, + organization = it.organizationName() ?: "", + authoredOn = it.authoredOn, redeemedOn = null, expiresOn = it.expiresOn, acceptUntil = it.acceptUntil, - status = mapStatus(it.status), - isDirectAssignment = it.taskId.startsWith(DIRECT_ASSIGNMENT_INDICATOR) + state = it.state(now = now), + isDirectAssignment = it.isDirectAssignment(), + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState( + isPartOfMultiplePrescription = it.medicationRequest + .multiplePrescriptionInfo.indicator, + numerator = it.medicationRequest + .multiplePrescriptionInfo.numbering?.numerator?.value, + denominator = it.medicationRequest + .multiplePrescriptionInfo.numbering?.denominator?.value, + start = it.medicationRequest.multiplePrescriptionInfo.start + ) ) } } @@ -81,38 +80,48 @@ interface PrescriptionUseCase { /** * Tasks grouped by timestamp. Mapped to [PrescriptionUseCaseData.Prescription.Scanned]. */ - fun scannedRecipes(): Flow> = - scannedTasks().map { tasks -> + fun scannedActiveRecipes(profileId: ProfileIdentifier): Flow> = + scannedTasks(profileId).map { tasks -> tasks - .filter { it.isRedeemable() } - .sortedWith(compareByDescending { it.scanSessionEnd }.thenBy { requireNotNull(it.nrInScanSession) }) + .filter { it.redeemedOn == null } + .sortedByDescending { it.scannedOn } .map { task -> PrescriptionUseCaseData.Prescription.Scanned( taskId = task.taskId, - scannedOn = requireNotNull(task.scanSessionEnd), + scannedOn = task.scannedOn, redeemedOn = task.redeemedOn ) } } - fun redeemedPrescriptions(): Flow> = + fun redeemedPrescriptions( + profileId: ProfileIdentifier, + now: Instant = Instant.now() + ): Flow> = combine( - scannedTasks(), - syncedTasks() + scannedTasks(profileId), + syncedTasks(profileId) ) { scannedTasks, syncedTasks -> val syncedPrescriptions = syncedTasks - .filter { it.isRedeemed() } + .filter { !it.isActive(now) } .map { PrescriptionUseCaseData.Prescription.Synced( taskId = it.taskId, - name = it.medicationText ?: "", - organization = it.organization ?: "", + isIncomplete = it.isIncomplete, + name = it.medicationName(), + organization = it.practitioner.name ?: it.organization.name ?: "", authoredOn = requireNotNull(it.authoredOn), - redeemedOn = it.redeemedOn, + redeemedOn = it.redeemedOn(), expiresOn = it.expiresOn, acceptUntil = it.acceptUntil, - status = mapStatus(it.status), - isDirectAssignment = it.taskId.startsWith(DIRECT_ASSIGNMENT_INDICATOR) + state = it.state(now), + isDirectAssignment = it.isDirectAssignment(), + multiplePrescriptionState = PrescriptionUseCaseData.Prescription.MultiplePrescriptionState( + isPartOfMultiplePrescription = it.medicationRequest.multiplePrescriptionInfo.indicator, + numerator = it.medicationRequest.multiplePrescriptionInfo.numbering?.numerator?.value, + denominator = it.medicationRequest.multiplePrescriptionInfo.numbering?.denominator?.value, + start = it.medicationRequest.multiplePrescriptionInfo.start + ) ) } @@ -121,91 +130,80 @@ interface PrescriptionUseCase { .map { task -> PrescriptionUseCaseData.Prescription.Scanned( taskId = task.taskId, - scannedOn = requireNotNull(task.scanSessionEnd), + scannedOn = task.scannedOn, redeemedOn = task.redeemedOn ) } (syncedPrescriptions + scannedPrescriptions) - .sortedWith(compareByDescending { it.redeemedOn }.thenBy { it.taskId }) - } - - fun unredeemedSyncedTaskIds(): Flow> = - syncedTasks().map { tasks -> - tasks.filter { it.isRedeemable() }.map { it.taskId } + .sortedWith( + compareByDescending { + it.redeemedOn ?: when (it) { + is PrescriptionUseCaseData.Prescription.Scanned -> it.scannedOn + is PrescriptionUseCaseData.Prescription.Synced -> it.authoredOn + } + }.thenBy { it.taskId } + ) } - fun unredeemedScannedTaskIds(): Flow> = - scannedTasks().map { tasks -> - tasks.filter { it.isRedeemable() }.map { it.taskId } + suspend fun saveScannedTasks(profileId: ProfileIdentifier, tasks: List) = + repository.saveScannedTasks(profileId, tasks) + + suspend fun saveScannedCodes(profileId: ProfileIdentifier, scannedCodes: List) { + val tasks = scannedCodes.flatMap { code -> + code.extract().map { (_, taskId, accessCode) -> + ScannedTaskData.ScannedTask( + profileId = "", + taskId = taskId, + accessCode = accessCode, + scannedOn = code.raw.scannedOn, + redeemedOn = null + ) + } } + tasks.takeIf { it.isNotEmpty() }?.let { saveScannedTasks(profileId, it) } + } - // TODO: add proper redeem logic - private fun Task.isRedeemable(): Boolean = this.redeemedOn == null - private fun Task.isRedeemed(): Boolean = !isRedeemable() - - private fun mapStatus(status: TaskStatus?): PrescriptionUseCaseData.Prescription.Synced.Status = - when (status) { - TaskStatus.Ready -> PrescriptionUseCaseData.Prescription.Synced.Status.Ready - TaskStatus.InProgress -> PrescriptionUseCaseData.Prescription.Synced.Status.InProgress - TaskStatus.Completed -> PrescriptionUseCaseData.Prescription.Synced.Status.Completed - else -> PrescriptionUseCaseData.Prescription.Synced.Status.Unknown + private fun ValidScannedCode.extract(): List> = + this.urls.mapNotNull { + TwoDCodeValidator.taskPattern.matchEntire(it)?.groupValues } - /** - * Throws an exception if any task doesn't match the requirements. - */ - suspend fun saveScannedTasks(tasks: List) - - /** - * Fetch tasks from the backend and store them into the database. - * The [Result] contains any errors - */ - suspend fun downloadTasks(profileName: String): Result + fun scannedTasks(profileId: ProfileIdentifier): Flow> = + repository.scannedTasks(profileId).flowOn(dispatchers.IO) - /** - * Fetch communications from the backend and store them into the database. - */ - suspend fun downloadCommunications(profileName: String): Result + fun syncedTasks(profileId: ProfileIdentifier): Flow> = + repository.syncedTasks(profileId).flowOn(dispatchers.IO) - /** - * Fetch audit events from the backend and store them into the database. - */ - fun downloadAllAuditEvents( - profileName: String - ) + suspend fun downloadTasks(profileId: ProfileIdentifier): Result = + repository.downloadTasks(profileId) + @OptIn(ExperimentalCoroutinesApi::class) suspend fun generatePrescriptionDetails( - taskId: String, - ): UIPrescriptionDetail - - suspend fun deletePrescription(taskId: String, isRemoteTask: Boolean): Result - - fun loadTasksForRedeemedOn( - redeemedOn: OffsetDateTime, - profileName: String - ): Flow> - - suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) - suspend fun loadLowDetailEvents(taskId: String): Flow> - suspend fun deleteLowDetailEvents(taskId: String) + taskId: String + ): Flow = + repository.loadSyncedTaskByTaskId(taskId).transformLatest { task -> + if (task == null) { + repository.loadScannedTaskByTaskId(taskId).collectLatest { scannedTask -> + if (scannedTask == null) { + Napier.w("No task `$taskId` found!") + } else { + emit(PrescriptionData.Scanned(task = scannedTask)) + } + } + } else { + emit(PrescriptionData.Synced(task = task)) + } + }.flowOn(dispatchers.IO) - suspend fun getAllTasksWithTaskIdOnly(): List - suspend fun redeem(taskIds: List, redeem: Boolean, all: Boolean) - suspend fun unRedeemMorePossible(taskId: String, profileName: String): Boolean - suspend fun editScannedPrescriptionsName(name: String, scanSessionEnd: OffsetDateTime) - suspend fun mapScannedCodeToTask(scannedCodes: List) -} + suspend fun deletePrescription(profileId: ProfileIdentifier, taskId: String): Result { + return repository.deleteTaskByTaskId(profileId, taskId) + } -fun createMatrixCode(payload: String): BitMatrix { - return DataMatrixWriter().encode(payload, BarcodeFormat.DATA_MATRIX, 1, 1) -} + suspend fun redeemScannedTask(taskId: String, redeem: Boolean) { + repository.updateRedeemedOn(taskId, if (redeem) Instant.now() else null) + } -fun createDataMatrixPayload(taskId: String, code: String): String { - val value = "Task/$taskId/\$accept?ac=$code" - val rootObject = JSONObject() - val urls = JSONArray() - urls.put(value) - rootObject.put("urls", urls) - return rootObject.toString().replace("\\", "") + fun getAllTasksWithTaskIdOnly(): Flow> = + repository.loadTaskIds() } diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDelegate.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDelegate.kt deleted file mode 100644 index 67a6440f..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDelegate.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.usecase - -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail -import de.gematik.ti.erp.app.prescription.ui.ValidScannedCode -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import java.time.OffsetDateTime -import javax.inject.Inject - -/** - * Manages Demo/Production switch until we can have module switching for our injection framework. - * If you would return a flow it is necessary to combine the demo and the production flow with the - * demoMode.demoModeActive flow to auto update - */ -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) -class PrescriptionUseCaseDelegate @Inject constructor( - private val demoDelegate: PrescriptionUseCaseDemo, - private val productionDelegate: PrescriptionUseCaseProduction, - private val demoUseCase: DemoUseCase -) : PrescriptionUseCase { - - private val delegate: PrescriptionUseCase - get() = if (demoUseCase.isDemoModeActive) demoDelegate else productionDelegate - - override fun tasks(): Flow> = - demoUseCase.demoModeActive.flatMapLatest { delegate.tasks() } - - override fun syncedTasks(): Flow> = - demoUseCase.demoModeActive.flatMapLatest { delegate.syncedTasks() } - - override fun scannedTasks(): Flow> = - demoUseCase.demoModeActive.flatMapLatest { delegate.scannedTasks() } - - override suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) { - delegate.saveLowDetailEvent(lowDetailEvent) - } - - override suspend fun loadLowDetailEvents(taskId: String): Flow> { - return delegate.loadLowDetailEvents(taskId) - } - - override suspend fun deleteLowDetailEvents(taskId: String) { - delegate.deleteLowDetailEvents(taskId) - } - - override suspend fun saveScannedTasks(tasks: List) { - delegate.saveScannedTasks(tasks) - } - - override suspend fun downloadTasks(profileName: String): Result { - return delegate.downloadTasks(profileName) - } - - override suspend fun downloadCommunications(profileName: String): Result { - return delegate.downloadCommunications(profileName) - } - - override fun downloadAllAuditEvents(profileName: String) = - delegate.downloadAllAuditEvents(profileName) - - override suspend fun generatePrescriptionDetails( - taskId: String - ): UIPrescriptionDetail { - return delegate.generatePrescriptionDetails(taskId) - } - - override suspend fun deletePrescription(taskId: String, isRemoteTask: Boolean) = - delegate.deletePrescription(taskId, isRemoteTask) - - override fun loadTasksForRedeemedOn(redeemedOn: OffsetDateTime, profileName: String): Flow> { - return delegate.loadTasksForRedeemedOn(redeemedOn, profileName) - } - - override suspend fun getAllTasksWithTaskIdOnly(): List { - return delegate.getAllTasksWithTaskIdOnly() - } - - override suspend fun redeem(taskIds: List, redeem: Boolean, all: Boolean) { - return delegate.redeem(taskIds, redeem, all) - } - - override suspend fun unRedeemMorePossible(taskId: String, profileName: String): Boolean { - return delegate.unRedeemMorePossible(taskId, profileName) - } - - override suspend fun editScannedPrescriptionsName( - name: String, - scanSessionEnd: OffsetDateTime - ) { - delegate.editScannedPrescriptionsName(name, scanSessionEnd) - } - - override suspend fun mapScannedCodeToTask(scannedCodes: List) { - delegate.mapScannedCodeToTask(scannedCodes) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDemo.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDemo.kt deleted file mode 100644 index 210786fd..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDemo.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.usecase - -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.api.map -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.idp.usecase.RefreshFlowException -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail -import de.gematik.ti.erp.app.prescription.detail.ui.model.mapToUIPrescriptionDetailScanned -import de.gematik.ti.erp.app.prescription.detail.ui.model.mapToUIPrescriptionDetailSynced -import de.gematik.ti.erp.app.prescription.repository.InsuranceCompanyDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationDetail -import de.gematik.ti.erp.app.prescription.repository.MedicationRequestDetail -import de.gematik.ti.erp.app.prescription.repository.OrganizationDetail -import de.gematik.ti.erp.app.prescription.repository.PatientDetail -import de.gematik.ti.erp.app.prescription.repository.PractitionerDetail -import de.gematik.ti.erp.app.prescription.repository.PrescriptionDemoDataSource -import de.gematik.ti.erp.app.prescription.ui.ValidScannedCode -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import java.io.IOException -import java.time.OffsetDateTime -import javax.inject.Inject -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.map - -private const val DEMO_DELAY = 500L - -class PrescriptionUseCaseDemo @Inject constructor( - private val prescriptionDemoDataSource: PrescriptionDemoDataSource, - private val demoUseCase: DemoUseCase -) : PrescriptionUseCase { - - override fun tasks(): Flow> = prescriptionDemoDataSource.tasks - - override fun scannedTasks(): Flow> = tasks().map { tasks -> - tasks.filter { - it.scannedOn != null - } - } - - override fun syncedTasks(): Flow> = tasks().map { tasks -> - tasks.filter { - it.scannedOn == null - } - } - - override suspend fun saveScannedTasks(tasks: List) { - prescriptionDemoDataSource.saveTasks(tasks) - } - - override suspend fun downloadCommunications(profileName: String): Result = Result.Success(Unit) - - override fun downloadAllAuditEvents(profileName: String) {} - - override suspend fun downloadTasks(profileName: String): Result { - delay(DEMO_DELAY) - return demoRefreshResult().map { Result.Success(0) } - } - - private fun demoRefreshResult(): Result { - return if (demoUseCase.authTokenReceived.value) { - prescriptionDemoDataSource.incrementRefresh() - Result.Success(Unit) - } else { - Result.Error(IOException(RefreshFlowException(true, null, "demo mode"))) - } - } - - private fun loadTaskByTaskId(taskId: String) = - prescriptionDemoDataSource.tasks.value - .find { it.taskId == taskId } - - override suspend fun generatePrescriptionDetails( - taskId: String, - ): UIPrescriptionDetail { - return loadTaskByTaskId(taskId) - ?.let { task -> - val payload = - task.accessCode?.let { createDataMatrixPayload("Task/${task.taskId}", it) } - val matrix = payload?.let { createMatrixCode(it) }?.let { BitMatrixCode(it) } - - if (task.rawKBVBundle != null) { - mapToUIPrescriptionDetailSynced( - task, - MedicationDetail(text = task.medicationText), - MedicationRequestDetail(), - null, - InsuranceCompanyDetail(), - OrganizationDetail(name = task.organization), - PatientDetail(), - PractitionerDetail(), - matrix - ) - } else { - mapToUIPrescriptionDetailScanned(task, matrix, false) - } - } ?: error("task $taskId not found") - } - - override suspend fun deletePrescription(taskId: String, isRemoteTask: Boolean): Result { - prescriptionDemoDataSource.deleteTaskByTaskId(taskId) - return Result.Success(Unit) - } - - override fun loadTasksForRedeemedOn( - redeemedOn: OffsetDateTime, - profileName: String - ): Flow> { - return emptyFlow() - } - - override suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) { - } - - override suspend fun loadLowDetailEvents(taskId: String): Flow> { - return emptyFlow() - } - - override suspend fun deleteLowDetailEvents(taskId: String) { - } - - override suspend fun getAllTasksWithTaskIdOnly(): List { - return prescriptionDemoDataSource.getAllTasksWithTaskIdOnly() - } - - override suspend fun redeem(taskIds: List, redeem: Boolean, all: Boolean) { - return prescriptionDemoDataSource.redeem(taskIds, redeem, all) - } - - override suspend fun unRedeemMorePossible(taskId: String, profileName: String): Boolean { - return prescriptionDemoDataSource.unRedeemMorePossible(taskId) - } - - override suspend fun editScannedPrescriptionsName( - name: String, - scanSessionEnd: OffsetDateTime - ) { - prescriptionDemoDataSource.editScannedPrescriptionsName(name, scanSessionEnd) - } - - override suspend fun mapScannedCodeToTask(scannedCodes: List) { - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProduction.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProduction.kt deleted file mode 100644 index 58befeb1..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProduction.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.usecase - -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail -import de.gematik.ti.erp.app.prescription.detail.ui.model.mapToUIPrescriptionDetailScanned -import de.gematik.ti.erp.app.prescription.detail.ui.model.mapToUIPrescriptionDetailSynced -import de.gematik.ti.erp.app.prescription.repository.Mapper -import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository -import de.gematik.ti.erp.app.prescription.repository.extractInsurance -import de.gematik.ti.erp.app.prescription.repository.extractMedication -import de.gematik.ti.erp.app.prescription.repository.extractMedicationRequest -import de.gematik.ti.erp.app.prescription.repository.extractOrganization -import de.gematik.ti.erp.app.prescription.repository.extractPatient -import de.gematik.ti.erp.app.prescription.repository.extractPractitioner -import de.gematik.ti.erp.app.prescription.ui.TwoDCodeValidator -import de.gematik.ti.erp.app.prescription.ui.ValidScannedCode -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import java.time.OffsetDateTime -import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.take - -@ExperimentalCoroutinesApi -class PrescriptionUseCaseProduction @Inject constructor( - private val repository: PrescriptionRepository, - private val mapper: Mapper, - private val profilesUseCase: ProfilesUseCase -) : PrescriptionUseCase { - - override suspend fun saveScannedTasks(tasks: List) { - repository.saveScannedTasks(tasks) - } - - override suspend fun mapScannedCodeToTask(scannedCodes: List) { - val activeProfileName = profilesUseCase.activeProfileName().first() - val now = OffsetDateTime.now() - var i = 1 - val tasks = scannedCodes.flatMap { code -> - code.extract().map { (_, taskId, accessCode) -> - Task( - taskId = taskId, - profileName = activeProfileName, - nrInScanSession = i++, - scanSessionName = "", - accessCode = accessCode, - scanSessionEnd = now, - scannedOn = code.raw.scannedOn - ) - } - } - tasks.takeIf { it.isNotEmpty() } - ?.let { saveScannedTasks(it) } - } - - private fun ValidScannedCode.extract(): List> = - this.urls.mapNotNull { - TwoDCodeValidator.taskPattern.matchEntire(it)?.groupValues - } - - override fun tasks() = - profilesUseCase.activeProfileName().flatMapLatest { - repository.tasks(it) - } - - override fun scannedTasks(): Flow> = - profilesUseCase.activeProfileName().flatMapLatest { - repository.scannedTasksWithoutBundle(it) - } - - override fun syncedTasks(): Flow> { - return profilesUseCase.activeProfileName().flatMapLatest { - repository.syncedTasksWithoutBundle(it) - } - } - - override suspend fun downloadTasks(profileName: String): Result = - repository.downloadTasks(profileName) - - override suspend fun downloadCommunications(profileName: String): Result = - repository.downloadCommunications(profileName) - - override fun downloadAllAuditEvents(profileName: String) = - repository.downloadAllAuditEvents(profileName) - - override suspend fun loadLowDetailEvents(taskId: String): Flow> = - repository.loadLowDetailEvents(taskId) - - override suspend fun deleteLowDetailEvents(taskId: String) { - repository.deleteLowDetailEvents(taskId) - } - - override suspend fun generatePrescriptionDetails( - taskId: String, - ): UIPrescriptionDetail { - - val (task, medicationDispense) = repository.loadTaskWithMedicationDispenseForTaskId(taskId) - .first() - val payload = task.accessCode?.let { createDataMatrixPayload(task.taskId, it) } - val matrix = payload?.let { createMatrixCode(it) }?.let { BitMatrixCode(it) } - val unRedeemMorePossible = unRedeemMorePossible(task.taskId, task.profileName) - - return if (task.rawKBVBundle == null) { - mapToUIPrescriptionDetailScanned(task, matrix, unRedeemMorePossible) - } else { - val bundle = mapper.parseKBVBundle(requireNotNull(task.rawKBVBundle)) - mapToUIPrescriptionDetailSynced( - task, - requireNotNull(bundle.extractMedication()), - requireNotNull(bundle.extractMedicationRequest()), - medicationDispense, - requireNotNull(bundle.extractInsurance()), - requireNotNull(bundle.extractOrganization()), - requireNotNull(bundle.extractPatient()), - requireNotNull(bundle.extractPractitioner()), - matrix - ) - } - } - - override suspend fun deletePrescription(taskId: String, isRemoteTask: Boolean): Result { - val activeProfileName = profilesUseCase.activeProfileName().first() - return repository.deleteTaskByTaskId(activeProfileName, taskId, isRemoteTask) - } - - override suspend fun redeem(taskIds: List, redeem: Boolean, all: Boolean) { - if (all) { - redeemAll(taskIds, redeem) - } else { - redeemSingle(taskIds.first(), redeem, OffsetDateTime.now()) - } - } - - private suspend fun redeemSingle(taskId: String, redeem: Boolean, tm: OffsetDateTime) { - if (redeem) { - repository.updateRedeemedOnForSingleTask(taskId, tm) - } else { - repository.updateRedeemedOnForSingleTask(taskId, null) - } - } - - private suspend fun redeemAll(taskIds: List, redeem: Boolean) { - val now = OffsetDateTime.now() - if (redeem) { - repository.updateRedeemedOnForAllTasks(taskIds, now) - } else { - repository.updateRedeemedOnForAllTasks(taskIds, null) - } - } - - override suspend fun unRedeemMorePossible(taskId: String, profileName: String): Boolean { - var unRedeemMorePossible = false - scannedTasks().take(1).collect { scannedTasks -> - scannedTasks.forEach { - if (it.taskId == taskId) { - val tasksForRedeemedOn = it.redeemedOn?.let { actualTask -> - loadTasksForRedeemedOn(actualTask, profileName) - } - tasksForRedeemedOn?.take(1)?.collect { tasksWithActualRedeemedOn -> - if (tasksWithActualRedeemedOn.size > 1) { - unRedeemMorePossible = true - } - } - } - } - } - return unRedeemMorePossible - } - - override suspend fun editScannedPrescriptionsName( - name: String, - scanSessionEnd: OffsetDateTime - ) { - if (name.isBlank()) { - repository.updateScanSessionName(null, scanSessionEnd) - } else { - repository.updateScanSessionName(name.trim(), scanSessionEnd) - } - } - - override fun loadTasksForRedeemedOn( - redeemedOn: OffsetDateTime, - profileName: String - ): Flow> { - return repository.loadTasksForRedeemedOn(redeemedOn, profileName) - } - - override suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) { - repository.saveLowDetailEvent(lowDetailEvent) - } - - override suspend fun getAllTasksWithTaskIdOnly(): List { - val activeProfileName = profilesUseCase.activeProfileName().first() - return repository.getAllTasksWithTaskIdOnly(activeProfileName) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/RefreshPrescriptionUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/RefreshPrescriptionUseCase.kt new file mode 100644 index 00000000..63d11a90 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/RefreshPrescriptionUseCase.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.prescription.usecase + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.orders.repository.CommunicationRepository +import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.protocol.repository.AuditEventsRepository +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RefreshPrescriptionUseCase( + private val repository: PrescriptionRepository, + private val communicationRepository: CommunicationRepository, + private val auditRepository: AuditEventsRepository, + dispatchers: DispatchProvider +) { + private class Request( + val resultChannel: Channel>, + val forProfileId: ProfileIdentifier + ) + + private val scope = CoroutineScope(dispatchers.IO) + + private val requestChannel = + Channel(onUndeliveredElement = { it.resultChannel.close(CancellationException()) }) + + private val _refreshInProgress = MutableStateFlow(false) + val refreshInProgress: StateFlow + get() = _refreshInProgress + + init { + scope.launch { + for (request in requestChannel) { + _refreshInProgress.value = true + Napier.d { "Start refreshing as per request" } + + val profileId = request.forProfileId + + val result = runCatching { + val nrOfNewPrescriptions = repository.downloadTasks(profileId).getOrThrow() + communicationRepository.downloadCommunications(profileId).getOrThrow() + nrOfNewPrescriptions + } + + // may be closed already + request.resultChannel.trySend(result) + + Napier.d { "Finished refreshing" } + _refreshInProgress.value = false + } + } + } + + suspend fun download(profileId: ProfileIdentifier): Result { + val resultChannel = Channel>() + try { + requestChannel.send(Request(resultChannel = resultChannel, forProfileId = profileId)) + scope.launch { + auditRepository.downloadAuditEvents(profileId).onFailure { + Napier.e(it) { "Failed to download audit events" } + } + } + + return resultChannel.receive() + } catch (cancellation: CancellationException) { + Napier.d { "Cancelled waiting for result of refresh request" } + withContext(NonCancellable) { + resultChannel.close(cancellation) + } + throw cancellation + } + } + + fun downloadFlow(profileId: ProfileIdentifier): Flow = + flow { + emit(download(profileId).getOrThrow()) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt index 4926f279..b978aa9a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt @@ -19,35 +19,42 @@ package de.gematik.ti.erp.app.prescription.usecase.model import androidx.compose.runtime.Immutable -import java.time.LocalDate -import java.time.OffsetDateTime +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import java.time.Instant object PrescriptionUseCaseData { /** * Individual prescription backed by its original task id. */ + @Immutable sealed class Prescription { abstract val taskId: String - abstract val redeemedOn: OffsetDateTime? + abstract val redeemedOn: Instant? + /** - * Represents a single [Task] synchronized with the backend. + * Represents a single [Task] synchronized with the backend. */ @Immutable data class Synced( override val taskId: String, - val name: String, + val state: SyncedTaskData.SyncedTask.TaskState, + val name: String?, + val isIncomplete: Boolean, val organization: String, - val authoredOn: OffsetDateTime, - override val redeemedOn: OffsetDateTime?, - val expiresOn: LocalDate?, - val acceptUntil: LocalDate?, - val status: Status, - val isDirectAssignment: Boolean - ) : Prescription() { - enum class Status { - Ready, InProgress, Completed, Unknown - } - } + val authoredOn: Instant, + override val redeemedOn: Instant?, + val expiresOn: Instant?, + val acceptUntil: Instant?, + val isDirectAssignment: Boolean, + val multiplePrescriptionState: MultiplePrescriptionState + ) : Prescription() + + data class MultiplePrescriptionState( + val isPartOfMultiplePrescription: Boolean = false, + val numerator: String? = null, + val denominator: String? = null, + val start: Instant? = null + ) /** * Represents a single [Task] scanned by the user. @@ -55,8 +62,14 @@ object PrescriptionUseCaseData { @Immutable data class Scanned( override val taskId: String, - val scannedOn: OffsetDateTime, - override val redeemedOn: OffsetDateTime? + val scannedOn: Instant, + override val redeemedOn: Instant? ) : Prescription() + + fun redeemedOrExpiredOn(): Instant = + when (this) { + is Scanned -> requireNotNull(redeemedOn) { "Scanned prescriptions require a redeemed timestamp" } + is Synced -> redeemedOn ?: expiresOn ?: authoredOn + } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ProfilesModule.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ProfilesModule.kt new file mode 100644 index 00000000..65786fcd --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ProfilesModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles + +import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository +import de.gematik.ti.erp.app.profiles.usecase.ProfileAvatarUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesWithPairedDevicesUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +val profilesModule = DI.Module("profilesModule") { + bindProvider { ProfileAvatarUseCase(instance(), instance()) } + bindProvider { ProfilesWithPairedDevicesUseCase(instance(), instance()) } + + bindSingleton { ProfilesRepository(instance(), instance()) } + bindSingleton { ProfilesUseCase(instance(), instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt deleted file mode 100644 index cfaa3447..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.profiles.repository - -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.room.withTransaction -import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.ActiveProfile -import de.gematik.ti.erp.app.db.entities.AuditEventWithMedicationText -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.db.entities.ProfileEntity -import de.gematik.ti.erp.app.settings.usecase.DEFAULT_PROFILE_NAME -import java.time.Instant -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch - -private const val eventsPerPage = 20 - -class KVNRAlreadyAssignedException( - message: String, - val isActiveProfile: Boolean, - val inProfile: String, - val insuranceIdentifier: String -) : IllegalStateException(message) - -class ProfilesRepository @Inject constructor( - private val db: AppDatabase, - dispatcher: DispatchProvider -) { - private val scope = CoroutineScope(dispatcher.io()) - - init { - scope.launch { - if (db.activeProfileDao().activeProfile() == null) { - db.profileDao().insertProfile(ProfileEntity(name = DEFAULT_PROFILE_NAME)) - db.activeProfileDao().insertActiveProfile(ActiveProfile(profileName = DEFAULT_PROFILE_NAME)) - } - } - } - - fun profiles() = - db.profileDao().getAllProfilesFlow() - - suspend fun saveProfile(profileName: String, activate: Boolean = false) { - db.withTransaction { - db.profileDao().insertProfile(ProfileEntity(name = profileName)) - if (activate) { - db.activeProfileDao().updateActiveProfile(profileName) - } - } - } - - suspend fun updateProfileByName(currentName: String, updatedName: String, activate: Boolean = false) { - db.withTransaction { - db.profileDao().updateProfileName(currentName, updatedName) - if (activate || profileIsActive(currentName)) { - db.activeProfileDao().updateActiveProfile(updatedName) - } - } - } - - private suspend fun profileIsActive(profileName: String) = - db.activeProfileDao().activeProfile()?.profileName == profileName - - suspend fun removeProfile(profileName: String) { - db.withTransaction { - if (profileIsActive(profileName)) { - val profiles = db.profileDao().getAllProfiles() - if (profiles.size == 1) { - error("Can't remove the last profile!") - } else { - saveProfile(profiles.find { it.name != profileName }!!.name, activate = true) - } - } - db.profileDao().removeProfileByName(profileName) - } - } - - fun activeProfile() = - db.activeProfileDao().activeProfileFlow().filterNotNull() - - fun getProfileById(profileId: Int) = db.profileDao().loadProfile(profileId) - - suspend fun updateProfileColor(profileName: String, color: ProfileColorNames) { - db.profileDao().updateProfileColor(profileName, color) - } - - suspend fun updateLastAuthenticated(validOn: Instant, profileName: String) = - db.profileDao().updateLastAuthenticated(validOn, profileName) - - fun loadAuditEventsForProfile(profileName: String): Flow> { - return Pager( - PagingConfig( - pageSize = eventsPerPage, - enablePlaceholders = false - ), - pagingSourceFactory = db.taskDao().getAuditEventsForProfileName(profileName).asPagingSourceFactory() - ).flow - } - - suspend fun setInsuranceInformation( - profileName: String, - insurantName: String, - insuranceIdentifier: String, - insuranceName: String - ) { - db.profileDao().getAllProfiles().let { profiles -> - profiles.find { it.insuranceIdentifier == insuranceIdentifier && it.name != profileName } - ?.let { - throw KVNRAlreadyAssignedException( - "KVNR already assigned to another profile", - false, - it.name, - it.insuranceIdentifier!! - ) - } - profiles.find { it.name == profileName } - ?.takeIf { - it.insuranceIdentifier != null && it.insuranceIdentifier != insuranceIdentifier - } - ?.let { - throw KVNRAlreadyAssignedException( - "Profile already assigned to another KVNR", - true, - profileName, - it.insuranceIdentifier!! - ) - } - } - db.profileDao().setInsuranceInformation(profileName, insurantName, insuranceIdentifier, insuranceName) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/Avatar.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/Avatar.kt index 9f3ff615..e8e99cbc 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/Avatar.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/Avatar.kt @@ -22,71 +22,142 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddAPhoto import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import de.gematik.ti.erp.app.utils.firstCharOfForeNameSurName +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults @Composable fun Avatar( - name: String, - profileColor: ProfileColor, + avatarModifier: Modifier, + emptyIcon: ImageVector, + profile: ProfilesUseCaseData.Profile, ssoStatusColor: Color?, - active: Boolean = false + active: Boolean = false, + iconModifier: Modifier ) { - Box { - val text = remember(name) { firstCharOfForeNameSurName(name) } - Box(modifier = Modifier.align(Alignment.Center), contentAlignment = Alignment.Center) { - CircleBox( - 36.dp, - profileColor.backGroundColor, - border = if (active) BorderStroke(2.dp, profileColor.borderColor) else null - ) - Text( - text = text, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.body2, - color = profileColor.textColor, - textAlign = TextAlign.Center - ) + val currentSelectedColors = profileColor(profileColorNames = profile.color) + + Box( + modifier = avatarModifier + .fillMaxSize() + .aspectRatio(1f), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .fillMaxSize(), + shape = CircleShape, + color = currentSelectedColors.backGroundColor, + border = if (active) BorderStroke(2.dp, currentSelectedColors.borderColor) else null + ) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ChooseAvatar( + profile = profile, + emptyIcon = emptyIcon, + iconModifier = iconModifier, + figure = profile.avatarFigure + ) + } } if (ssoStatusColor != null) { CircleBox( - size = 16.dp, backgroundColor = ssoStatusColor, border = BorderStroke(2.dp, MaterialTheme.colors.background), - modifier = Modifier.align(Alignment.BottomEnd).offset(4.dp, 4.dp) + modifier = Modifier + .size(PaddingDefaults.Medium) + .align(Alignment.BottomEnd) + .offset(PaddingDefaults.Tiny, PaddingDefaults.Tiny) ) } } } @Composable -private fun CircleBox( - size: Dp, +fun CircleBox( backgroundColor: Color, - modifier: Modifier = Modifier, + modifier: Modifier, border: BorderStroke? = null ) { Box( modifier = modifier - .size(size) .clip(CircleShape) + .aspectRatio(1f) .background(backgroundColor) .then( border?.let { Modifier.border(border, CircleShape) } ?: Modifier ) ) } + +@Preview +@Composable +private fun AvatarPreview() { + AppTheme { + Avatar( + avatarModifier = Modifier.size(36.dp), + profile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SUN_DEW, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage, + personalizedImage = null, + lastAuthenticated = null, + ssoTokenScope = null + ), + ssoStatusColor = null, + active = false, + iconModifier = Modifier.size(20.dp), + emptyIcon = Icons.Rounded.AddAPhoto + ) + } +} + +@Preview +@Composable +private fun AvatarWithSSOPreview() { + AppTheme { + Avatar( + avatarModifier = Modifier.size(36.dp), + profile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + + color = ProfilesData.ProfileColorNames.SUN_DEW, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage, + personalizedImage = null, + lastAuthenticated = null, + ssoTokenScope = null + ), + ssoStatusColor = Color.Green, + active = false, + iconModifier = Modifier.size(20.dp), + emptyIcon = Icons.Rounded.AddAPhoto + ) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt index e21bdc13..bdc23fa9 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileNavigation.kt @@ -21,6 +21,8 @@ package de.gematik.ti.erp.app.profiles.ui import AuditEventsScreen import TokenScreen import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -37,6 +39,9 @@ object ProfileDestinations { object Profile : Route("profile") object Token : Route("token") object AuditEvents : Route("auditEvents") + object PairedDevices : Route("pairedDevices") + object ProfileImagePicker : Route("profileImagePicker") + object ProfileImageCropper : Route("imageCropper") } @Composable @@ -44,51 +49,99 @@ fun EditProfileNavGraph( state: SettingsScreen.State, navController: NavHostController, onBack: () -> Unit, - profile: ProfilesUseCaseData.Profile, + selectedProfile: ProfilesUseCaseData.Profile, settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel, onRemoveProfile: (newProfileName: String?) -> Unit, - mainNavController: NavController, + mainNavController: NavController ) { - NavHost(navController = navController, startDestination = ProfileDestinations.Profile.route) { composable(ProfileDestinations.Profile.route) { EditProfileScreenContent( onClickToken = { navController.navigate(ProfileDestinations.Token.path()) }, onClickAuditEvents = { navController.navigate(ProfileDestinations.AuditEvents.path()) }, - ssoTokenValid = profile.ssoTokenValid(), onClickLogIn = { - settingsViewModel.switchProfile(profile) + settingsViewModel.switchProfile(selectedProfile) mainNavController.navigate( - MainNavigationScreens.CardWall.path(settingsViewModel.isCanAvailable(profile)) + MainNavigationScreens.CardWall.path(selectedProfile.id) ) }, + onClickLogout = { settingsViewModel.logout(selectedProfile) }, onBack = onBack, state = state, settingsViewModel = settingsViewModel, - selectedProfile = profile, - onRemoveProfile = onRemoveProfile + profileSettingsViewModel = profileSettingsViewModel, + selectedProfile = selectedProfile, + onRemoveProfile = onRemoveProfile, + onClickEditAvatar = { navController.navigate(ProfileDestinations.ProfileImagePicker.path()) }, + onClickPairedDevices = { + navController.navigate(ProfileDestinations.PairedDevices.path()) + } ) } + + composable(ProfileDestinations.ProfileImagePicker.route) { + ProfileColorAndImagePicker( + selectedProfile, + clearPersonalizedImage = { + profileSettingsViewModel.clearPersonalizedImage(selectedProfile.id) + }, + onBack = { navController.popBackStack() }, + onPickPersonalizedImage = { + navController.navigate(ProfileDestinations.ProfileImageCropper.path()) + }, + onSelectAvatar = { avatar -> + profileSettingsViewModel.saveAvatarFigure(selectedProfile.id, avatar) + }, + onSelectProfileColor = { color -> + profileSettingsViewModel.updateProfileColor(selectedProfile, color) + } + ) + } + + composable( + ProfileDestinations.ProfileImageCropper.route + ) { + ProfileImageCropper( + onSaveCroppedImage = { + profileSettingsViewModel.savePersonalizedProfileImage(selectedProfile.id, it) + navController.popBackStack() + }, + onBack = { + navController.popBackStack() + } + ) + } + composable(ProfileDestinations.Token.route) { + val accessToken by settingsViewModel.decryptedAccessToken(selectedProfile).collectAsState(null) + NavigationAnimation(mode = NavigationMode.Closed) { TokenScreen( onBack = { navController.popBackStack() }, - ssoToken = profile.ssoToken?.tokenOrNull(), - accessToken = profile.accessToken, + ssoToken = selectedProfile.ssoTokenScope?.token?.token, + accessToken = accessToken ) } } - composable( - ProfileDestinations.AuditEvents.route, - ) { + composable(ProfileDestinations.AuditEvents.route) { NavigationAnimation(mode = NavigationMode.Closed) { AuditEventsScreen( - profile.name, - settingsViewModel, - profile.lastAuthenticated, - profile.ssoTokenValid(), + profileId = selectedProfile.id, + viewModel = settingsViewModel, + lastAuthenticated = selectedProfile.lastAuthenticated, + tokenValid = selectedProfile.ssoTokenValid() ) { navController.popBackStack() } } } + composable(ProfileDestinations.PairedDevices.route) { + NavigationAnimation(mode = NavigationMode.Closed) { + PairedDevicesScreen( + selectedProfile = selectedProfile, + settingsViewModel = settingsViewModel, + onBack = { navController.popBackStack() } + ) + } + } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt index 5eb85a83..a55f0a58 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/EditProfileScreen.kt @@ -18,14 +18,16 @@ package de.gematik.ti.erp.app.profiles.ui -import android.widget.Toast -import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -33,83 +35,103 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme +import androidx.compose.material.IconButton import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudQueue import androidx.compose.material.icons.outlined.Done +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext - +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics - -import androidx.compose.ui.text.font.FontWeight - +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.em import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.icons.rounded.AddAPhoto +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.db.entities.ProfileColorNames +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.TestTag.Profile.OpenTokensScreenButton +import de.gematik.ti.erp.app.TestTag.Profile.ProfileScreen +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.model.ProfilesData import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import de.gematik.ti.erp.app.settings.ui.AddProfileDialog +import de.gematik.ti.erp.app.settings.ui.ProfileNameDialog import de.gematik.ti.erp.app.settings.ui.SettingsScreen import de.gematik.ti.erp.app.settings.ui.SettingsViewModel import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog -import de.gematik.ti.erp.app.utils.compose.HintCard -import de.gematik.ti.erp.app.utils.compose.HintCardDefaults -import de.gematik.ti.erp.app.utils.compose.HintSmallImage -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton -import de.gematik.ti.erp.app.utils.compose.ProfileNameInputField -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer24 -import de.gematik.ti.erp.app.utils.compose.Spacer4 -import de.gematik.ti.erp.app.utils.compose.Spacer40 -import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.DynamicText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.SpacerTiny import de.gematik.ti.erp.app.utils.compose.annotatedStringResource -import de.gematik.ti.erp.app.utils.firstCharOfForeNameSurName -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import java.util.Locale +import de.gematik.ti.erp.app.utils.compose.visualTestTag +import de.gematik.ti.erp.app.utils.sanitizeProfileName +import kotlinx.coroutines.launch +import java.time.Instant @Composable fun EditProfileScreen( state: SettingsScreen.State, profile: ProfilesUseCaseData.Profile, settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel, onRemoveProfile: (newProfileName: String?) -> Unit, onBack: () -> Unit, - mainNavController: NavController, + mainNavController: NavController ) { val navController = rememberNavController() @@ -117,8 +139,9 @@ fun EditProfileScreen( state = state, navController = navController, onBack = onBack, - profile = profile, + selectedProfile = profile, settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel, onRemoveProfile = onRemoveProfile, mainNavController = mainNavController ) @@ -126,10 +149,11 @@ fun EditProfileScreen( @Composable fun EditProfileScreen( - profileId: Int, + profileId: String, settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel, onBack: () -> Unit, - mainNavController: NavController, + mainNavController: NavController ) { val state by produceState(initialValue = SettingsScreen.defaultState) { settingsViewModel.screenState().collect { @@ -138,11 +162,15 @@ fun EditProfileScreen( } state.profileById(profileId)?.let { profile -> + val selectedProfile = remember(profile) { + profile + } EditProfileScreen( state = state, onBack = onBack, - profile = profile, + profile = selectedProfile, settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel, onRemoveProfile = { settingsViewModel.removeProfile(profile, it) onBack() @@ -152,82 +180,98 @@ fun EditProfileScreen( } } +@Suppress("LongParameterList") @Composable fun EditProfileScreenContent( onBack: () -> Unit, selectedProfile: ProfilesUseCaseData.Profile, state: SettingsScreen.State, settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel, onRemoveProfile: (newProfileName: String?) -> Unit, + onClickEditAvatar: () -> Unit, onClickToken: () -> Unit, - ssoTokenValid: Boolean = false, onClickLogIn: () -> Unit, - onClickAuditEvents: () -> Unit + onClickLogout: () -> Unit, + onClickAuditEvents: () -> Unit, + onClickPairedDevices: () -> Unit ) { val listState = rememberLazyListState() + var showAddDefaultProfileDialog by remember { mutableStateOf(false) } + var deleteProfileDialogVisible by remember { mutableStateOf(false) } + + if (deleteProfileDialogVisible) { + deleteProfileDialog( + onCancel = { deleteProfileDialogVisible = false }, + onClickAction = { + if (state.profiles.size == 1) { + showAddDefaultProfileDialog = true + } else { + onRemoveProfile(null) + } + deleteProfileDialogVisible = false + } + ) + } AnimatedElevationScaffold( + modifier = Modifier + .imePadding() + .visualTestTag(ProfileScreen), topBarTitle = stringResource(R.string.edit_profile_title), + navigationMode = NavigationBarMode.Back, listState = listState, - onBack = onBack, + actions = { + ThreeDotMenu( + selectedProfile = selectedProfile, + onClickLogIn = onClickLogIn, + onClickLogout = onClickLogout, + onClickDelete = { deleteProfileDialogVisible = true } + ) + }, + onBack = onBack ) { - var showAddDefaultProfileDialog by remember { mutableStateOf(false) } - LazyColumn( - modifier = Modifier.testTag("edit_profile_screen"), + modifier = Modifier.testTag(TestTag.Profile.ProfileScreenContent), state = listState, - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyStart = false, - applyTop = false, - applyEnd = false, - applyBottom = true - ) + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() ) { - if (!selectedProfile.connected()) { - item { - ConnectProfileHint(onClickLogIn = onClickLogIn) - } - } item { - ColorAndProfileNameSection( + ProfileNameSection( profile = selectedProfile, state = state, onChangeProfileName = { - settingsViewModel.updateProfileName(selectedProfile, it) - }, - onSelectProfileColor = { - settingsViewModel.updateProfileColor(selectedProfile, it) + profileSettingsViewModel.updateProfileName(selectedProfile.id, it) } ) } - item { SecuritySection(onClickToken, onClickAuditEvents, selectedProfile.ssoToken != null) } item { - if (ssoTokenValid) { - LogoutButton(onClick = { - settingsViewModel.logout(selectedProfile) - }) - } else - LoginButton( - onClick = { onClickLogIn() } - ) + SpacerLarge() + ProfileAvatarSection( + profile = selectedProfile, + onClickEditAvatar = onClickEditAvatar + ) } item { - RemoveProfileSection( - onClickRemoveProfile = { - if (state.uiProfiles.size == 1) { - showAddDefaultProfileDialog = true - } else { - onRemoveProfile(null) - } - } + ProfileInsuranceInformation( + selectedProfile.lastAuthenticated, + selectedProfile.ssoTokenScope, + selectedProfile.insuranceInformation, + onClickLogIn ) } + + if (selectedProfile.ssoTokenScope != null) { + item { + ProfileEditPairedDeviceSection(onShowPairedDevices = onClickPairedDevices) + } + } + item { SecuritySection(onClickToken, onClickAuditEvents) } } if (showAddDefaultProfileDialog) { - AddProfileDialog( - state = state, + ProfileNameDialog( + settingsViewModel = settingsViewModel, wantRemoveLastProfile = true, onEdit = { showAddDefaultProfileDialog = false; onRemoveProfile(it) }, onDismissRequest = { showAddDefaultProfileDialog = false } @@ -237,40 +281,82 @@ fun EditProfileScreenContent( } @Composable -fun ConnectProfileHint(onClickLogIn: () -> Unit) { - HintCard( - modifier = Modifier.padding(PaddingDefaults.Medium), - properties = HintCardDefaults.properties( - backgroundColor = AppTheme.colors.primary100, - border = BorderStroke(0.0.dp, AppTheme.colors.primary100), - elevation = 0.dp - ), - image = { - HintSmallImage( - painterResource(R.drawable.connect_profile), - innerPadding = it +fun ThreeDotMenu( + selectedProfile: ProfilesUseCaseData.Profile, + onClickLogIn: () -> Unit, + onClickLogout: () -> Unit, + onClickDelete: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + IconButton( + onClick = { expanded = true }, + modifier = Modifier.testTag(TestTag.Profile.ThreeDotMenuButton) + ) { + Icon(Icons.Rounded.MoreVert, null, tint = AppTheme.colors.neutral600) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(24.dp, 0.dp) + ) { + DropdownMenuItem( + modifier = Modifier.testTag( + if (selectedProfile.ssoTokenScope != null) { + TestTag.Profile.LogoutButton + } else { + TestTag.Profile.LoginButton + } + ), + onClick = if (selectedProfile.ssoTokenScope != null) { + onClickLogout + } else { + onClickLogIn + } + ) { + Text( + text = if (selectedProfile.ssoTokenScope != null) { + stringResource(R.string.insurance_information_logout) + } else { + stringResource(R.string.insurance_information_login) + } ) - }, - title = { Text(stringResource(R.string.connect_profile_header)) }, - body = { Text(stringResource(R.string.connect_profile_info)) }, - action = { - HintTextActionButton( - text = stringResource(R.string.connect_profile_connect), - onClick = onClickLogIn, + } + + DropdownMenuItem( + modifier = Modifier.testTag(TestTag.Profile.DeleteProfileButton), + onClick = { + expanded = false + onClickDelete() + } + ) { + Text( + text = stringResource(R.string.remove_profile), + color = AppTheme.colors.red600 ) } - ) { } } +@Composable +fun deleteProfileDialog(onCancel: () -> Unit, onClickAction: () -> Unit) { + CommonAlertDialog( + header = stringResource(id = R.string.remove_profile_header), + info = stringResource(R.string.remove_profile_detail_message), + actionText = stringResource(R.string.remove_profile_yes), + cancelText = stringResource(R.string.remove_profile_no), + onCancel = onCancel, + onClickAction = onClickAction + ) +} + @Composable fun SecuritySection( onClickToken: () -> Unit, - onClickAuditEvents: () -> Unit, - tokenAvailable: Boolean + onClickAuditEvents: () -> Unit ) { - SecurityHeadline() - SecurityTokenSubSection(tokenAvailable, onClickToken) + SettingsMenuHeadline(stringResource(R.string.settings_appprotection_headline)) + SecurityTokenSubSection(onClickToken) SecurityAuditEventsSubSection(onClickAuditEvents) } @@ -286,16 +372,17 @@ fun SecurityAuditEventsSubSection(onClickAuditEvents: () -> Unit) { onClickAuditEvents() } ) + .testTag(TestTag.Profile.OpenAuditEventsScreenButton) .padding(PaddingDefaults.Medium) - .semantics(mergeDescendants = true) {}, + .semantics(mergeDescendants = true) {} ) { - Icon(Icons.Outlined.CloudQueue, null, tint = AppTheme.colors.primary500) + Icon(Icons.Outlined.CloudQueue, null, tint = AppTheme.colors.primary600) Column { Text( stringResource( R.string.settings_show_audit_events ), - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) Text( stringResource( @@ -308,47 +395,26 @@ fun SecurityAuditEventsSubSection(onClickAuditEvents: () -> Unit) { } @Composable -fun SecurityTokenSubSection(tokenAvailable: Boolean, onClick: () -> Unit) { - val context = LocalContext.current - val noTokenAvailableText = stringResource(R.string.settings_no_active_token) - - val iconColor = if (tokenAvailable) { - AppTheme.colors.primary500 - } else { - AppTheme.colors.primary300 - } - - val textColor = if (tokenAvailable) { - AppTheme.colors.neutral999 - } else { - AppTheme.colors.neutral600 - } +fun SecurityTokenSubSection(onClick: () -> Unit) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, modifier = Modifier .fillMaxWidth() .clickable( - onClick = { - if (tokenAvailable) { - onClick() - } else { - Toast - .makeText(context, noTokenAvailableText, Toast.LENGTH_SHORT) - .show() - } - } + onClick = { onClick() } ) + .visualTestTag(OpenTokensScreenButton) .padding(PaddingDefaults.Medium) - .semantics(mergeDescendants = true) {}, + .semantics(mergeDescendants = true) {} ) { - Icon(Icons.Outlined.VpnKey, null, tint = iconColor) + Icon(Icons.Outlined.VpnKey, null, tint = AppTheme.colors.primary600) Column { Text( stringResource( R.string.settings_show_token ), - style = MaterialTheme.typography.body1, color = textColor + style = AppTheme.typography.body1 ) Text( stringResource( @@ -361,114 +427,169 @@ fun SecurityTokenSubSection(tokenAvailable: Boolean, onClick: () -> Unit) { } @Composable -private fun SecurityHeadline() { +fun SettingsMenuHeadline(text: String) { + Text( + text = text, + style = AppTheme.typography.h6, + modifier = Modifier.padding(PaddingDefaults.Medium) + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ProfileNameSection( + profile: ProfilesUseCaseData.Profile, + state: SettingsScreen.State, + onChangeProfileName: (String) -> Unit +) { + var profileName by remember(profile.name) { mutableStateOf(profile.name) } + var profileNameValid by remember { mutableStateOf(true) } + var textFieldEnabled by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(textFieldEnabled) { + if (textFieldEnabled) { + focusRequester.requestFocus() + keyboardController?.show() + } + } + Column { - Column( - modifier = Modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(8.dp) + Row( + modifier = Modifier.padding(PaddingDefaults.Medium) ) { + if (!textFieldEnabled) { + val txt = buildAnnotatedString { + append(profileName) + append(" ") + appendInlineContent("edit", "edit") + } + val c = mapOf( + "edit" to InlineTextContent( + Placeholder( + width = 0.em, + height = 0.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter + ) + ) { + Icon(Icons.Outlined.Edit, null, tint = AppTheme.colors.neutral400) + } + ) + DynamicText( + txt, + style = AppTheme.typography.h5, + inlineContent = c, + modifier = Modifier + .clickable { + textFieldEnabled = true + } + .testTag(TestTag.Profile.EditProfileNameButton) + ) + } else { + ProfileEditBasicTextField( + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .testTag(TestTag.Profile.NewProfileNameField), + enabled = textFieldEnabled, + initialProfileName = profile.name, + onChangeProfileName = { name: String, isValid: Boolean -> + profileName = name + profileNameValid = isValid + }, + state = state, + onDone = { + if (profileNameValid) { + onChangeProfileName(profileName) + textFieldEnabled = false + scope.launch { keyboardController?.hide() } + } + } + ) + } + } + + if (!profileNameValid) { + SpacerTiny() + val errorText = if (profileName.isBlank()) { + stringResource(R.string.edit_profile_empty_profile_name) + } else { + stringResource(R.string.edit_profile_duplicated_profile_name) + } + Text( - text = stringResource(R.string.settings_appprotection_headline), - style = MaterialTheme.typography.h6 + text = errorText, + color = AppTheme.colors.red600, + style = AppTheme.typography.caption1, + modifier = Modifier.padding(start = PaddingDefaults.Medium) ) } } } @Composable -private fun LoginButton(onClick: () -> Unit) { - LoginLogoutButton( - onClick = onClick, - buttonText = R.string.login_profile, - buttonDescription = R.string.login_description, - contentColor = AppTheme.colors.primary700 - ) -} - -@Composable -private fun LogoutButton(onClick: () -> Unit) { - var dialogVisible by remember { mutableStateOf(false) } - - if (dialogVisible) { - CommonAlertDialog( - header = stringResource(id = R.string.logout_detail_header), - info = stringResource(R.string.logout_detail_message), - actionText = stringResource(R.string.logout_delete_yes), - cancelText = stringResource(R.string.logout_delete_no), - onCancel = { dialogVisible = false }, - onClickAction = { - onClick() - dialogVisible = false - } +fun ProfileEditBasicTextField( + modifier: Modifier, + enabled: Boolean, + textStyle: TextStyle = AppTheme.typography.h5, + initialProfileName: String, + onChangeProfileName: (String, Boolean) -> Unit, + state: SettingsScreen.State, + onDone: () -> Unit +) { + var profileNameState by remember { + mutableStateOf( + TextFieldValue( + text = initialProfileName, + selection = TextRange(initialProfileName.length) + ) ) } - LoginLogoutButton( - onClick = { dialogVisible = true }, - buttonText = R.string.logout_profile, - buttonDescription = R.string.logout_description, - contentColor = AppTheme.colors.red700 - ) -} - -@Composable -private fun LoginLogoutButton( - onClick: () -> Unit, - @StringRes buttonText: Int, - @StringRes buttonDescription: Int, - contentColor: Color -) { - Button( - onClick = { onClick() }, - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp, - top = 32.dp, - bottom = 16.dp + val color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + val mergedTextStyle = textStyle.merge(TextStyle(color = color)) + + BasicTextField( + value = profileNameState, + onValueChange = { + val name = sanitizeProfileName(it.text.trimStart()) + profileNameState = TextFieldValue( + text = name, + selection = it.selection, + composition = it.composition ) - .fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.neutral050, - contentColor = contentColor - ) - ) { - Text( - stringResource(buttonText).uppercase(Locale.getDefault()), - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 8.dp + + onChangeProfileName( + name, + name.trim().equals(initialProfileName, true) || + !state.containsProfileWithName(name) && name.isNotEmpty() ) - ) - } - Text( - stringResource(buttonDescription), - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - bottom = PaddingDefaults.Small + }, + enabled = enabled, + singleLine = !enabled, + textStyle = mergedTextStyle, + modifier = modifier, + cursorBrush = SolidColor(color), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done ), - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center + keyboardActions = KeyboardActions(onDone = { onDone() }) ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable -fun ColorAndProfileNameSection( +fun ProfileAvatarSection( profile: ProfilesUseCaseData.Profile, - state: SettingsScreen.State, - onChangeProfileName: (String) -> Unit, - onSelectProfileColor: (ProfileColorNames) -> Unit + onClickEditAvatar: () -> Unit ) { val currentSelectedColors = profileColor(profileColorNames = profile.color) - var profileName by rememberSaveable(profile.name) { mutableStateOf(profile.name) } - var profileNameError by remember { mutableStateOf(false) } - val initials = remember(profile.name) { firstCharOfForeNameSurName(profile.name) } - Column( modifier = Modifier .fillMaxSize() @@ -483,146 +604,218 @@ fun ColorAndProfileNameSection( color = currentSelectedColors.backGroundColor ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onClickEditAvatar), contentAlignment = Alignment.Center ) { - Text( - text = initials, - style = MaterialTheme.typography.body2, - color = currentSelectedColors.textColor, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - fontSize = 60.sp, + ChooseAvatar( + emptyIcon = Icons.Rounded.AddAPhoto, + iconModifier = Modifier.size(24.dp), + profile = profile, + figure = profile.avatarFigure ) } } + SpacerSmall() + TextButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .testTag(TestTag.Profile.EditProfileImageButton), + onClick = onClickEditAvatar + ) { + Text(text = stringResource(R.string.edit_profile_avatar), textAlign = TextAlign.Center) + } + } +} - LaunchedEffect(profileName) { - if (!profileNameError) { - delay(500) - onChangeProfileName(profileName) +@Composable +fun ChooseAvatar( + useSmallImages: Boolean? = false, + profile: ProfilesUseCaseData.Profile, + iconModifier: Modifier = Modifier, + emptyIcon: ImageVector, + showPersonalizedImage: Boolean = true, + figure: ProfilesData.AvatarFigure +) { + val imageRessource = ExtractImageResource(useSmallImages, figure) + + when (figure) { + ProfilesData.AvatarFigure.PersonalizedImage -> { + if (showPersonalizedImage) { + if (profile.personalizedImage != null) { + BitmapImage(profile) + } else { + Icon( + emptyIcon, + modifier = iconModifier, + contentDescription = null, + tint = AppTheme.colors.neutral600 + ) + } } } - val keyboardController = LocalSoftwareKeyboardController.current + else -> { + if (imageRessource == 0) { + Icon( + emptyIcon, + modifier = iconModifier, + contentDescription = null, + tint = AppTheme.colors.neutral600 + ) + } else { + Image( + painterResource(id = imageRessource), + null, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} - Spacer40() - ProfileNameInputField( - modifier = Modifier - .testTag("editProfile/profile_text_input") - .fillMaxWidth(), - value = profileName, - onValueChange = { - profileName = it.trimStart() - profileNameError = profileName.isEmpty() || - ( - profileName.trim() != profile.name && state.containsProfileWithName( - profileName - ) - ) - }, - onSubmit = { - if (!profileNameError) { - onChangeProfileName(profileName) - keyboardController?.hide() - } - }, - isError = profileNameError, - ) +@Composable +private fun ExtractImageResource( + useSmallImages: Boolean? = false, + figure: ProfilesData.AvatarFigure +) = if (useSmallImages == true) { + when (figure) { + ProfilesData.AvatarFigure.FemaleDoctor -> R.drawable.femal_doctor_small_portrait + ProfilesData.AvatarFigure.WomanWithHeadScarf -> R.drawable.woman_with_head_scarf_small_portrait + ProfilesData.AvatarFigure.Grandfather -> R.drawable.grand_father_small_portrait + ProfilesData.AvatarFigure.BoyWithHealthCard -> R.drawable.boy_with_health_card_small_portrait + ProfilesData.AvatarFigure.OldManOfColor -> R.drawable.old_man_of_color_small_portrait + ProfilesData.AvatarFigure.WomanWithPhone -> R.drawable.woman_with_phone_small_portrait + ProfilesData.AvatarFigure.Grandmother -> R.drawable.grand_mother_small_portrait + ProfilesData.AvatarFigure.ManWithPhone -> R.drawable.man_with_phone_small_portrait + ProfilesData.AvatarFigure.WheelchairUser -> R.drawable.wheel_chair_user_small_portrait + ProfilesData.AvatarFigure.Baby -> R.drawable.baby_small_portrait + ProfilesData.AvatarFigure.MaleDoctorWithPhone -> R.drawable.doctor_with_phone_small_portrait + ProfilesData.AvatarFigure.FemaleDoctorWithPhone -> R.drawable.femal_doctor_with_phone_small_portrait + ProfilesData.AvatarFigure.FemaleDeveloper -> R.drawable.femal_developer_small_portrait + else -> 0 + } +} else { + when (figure) { + ProfilesData.AvatarFigure.FemaleDoctor -> R.drawable.femal_doctor_portrait + ProfilesData.AvatarFigure.WomanWithHeadScarf -> R.drawable.woman_with_head_scarf_portrait + ProfilesData.AvatarFigure.Grandfather -> R.drawable.grand_father_portrait + ProfilesData.AvatarFigure.BoyWithHealthCard -> R.drawable.boy_with_health_card_portrait + ProfilesData.AvatarFigure.OldManOfColor -> R.drawable.old_man_of_color_portrait + ProfilesData.AvatarFigure.WomanWithPhone -> R.drawable.woman_with_phone_portrait + ProfilesData.AvatarFigure.Grandmother -> R.drawable.grand_mother_portrait + ProfilesData.AvatarFigure.ManWithPhone -> R.drawable.man_with_phone_portrait + ProfilesData.AvatarFigure.WheelchairUser -> R.drawable.wheel_chair_user_portrait + ProfilesData.AvatarFigure.Baby -> R.drawable.baby_portrait + ProfilesData.AvatarFigure.MaleDoctorWithPhone -> R.drawable.doctor_with_phone_portrait + ProfilesData.AvatarFigure.FemaleDoctorWithPhone -> R.drawable.femal_doctor_with_phone_portrait + ProfilesData.AvatarFigure.FemaleDeveloper -> R.drawable.femal_developer_portrait + else -> 0 + } +} - val errorText = if (profileName.isEmpty()) { - stringResource(R.string.edit_profile_empty_profile_name) - } else { - stringResource(R.string.edit_profile_duplicated_profile_name) +@Composable +fun BitmapImage(profile: ProfilesUseCaseData.Profile) { + val bitmap by produceState(initialValue = null, profile) { + value = profile.personalizedImage?.let { + BitmapFactory.decodeByteArray(profile.personalizedImage, 0, it.size).asImageBitmap() } + } - if (profileNameError) { - Spacer4() - Text( - text = errorText, - color = AppTheme.colors.red600, - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(start = PaddingDefaults.Medium) - ) - } - SpacerMedium() - ProfileConnectedCard(profile.insuranceInformation) - Spacer40() + bitmap?.let { + Image( + bitmap = it, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +fun ProfileInsuranceInformation( + lastAuthenticated: Instant?, + ssoTokenScope: IdpData.SingleSignOnTokenScope?, + insuranceInformation: ProfilesUseCaseData.ProfileInsuranceInformation, + onClickLogIn: () -> Unit +) { + SpacerLarge() + val cardAccessNumber = if (ssoTokenScope is IdpData.TokenWithHealthCardScope) { + ssoTokenScope.cardAccessNumber + } else { + null + } + + Column { Text( - stringResource(R.string.edit_profile_background_color), - style = MaterialTheme.typography.h6 + stringResource( + id = R.string.insurance_information_header + ), + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + style = AppTheme.typography.h6 ) + SpacerSmall() - Spacer24() - Row( - horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - ProfileColorNames.values().forEach { - val currentValueColors = profileColor(profileColorNames = it) - ColorSelector( - profileColorName = it, - selected = currentValueColors == currentSelectedColors, - onSelectColor = onSelectProfileColor - ) + if (lastAuthenticated != null) { + LabeledText( + stringResource(R.string.insurance_information_insurant_name), + insuranceInformation.insurantName + ) + LabeledText( + stringResource(R.string.insurance_information_insurance_name), + insuranceInformation.insuranceName + ) + cardAccessNumber?.let { + LabeledText(stringResource(R.string.insurance_information_insurant_can), it) } - } - Spacer16() - Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { - Text( - currentSelectedColors.colorName, - style = AppTheme.typography.body2l + LabeledText( + stringResource(R.string.insurance_information_insurance_identifier), + insuranceInformation.insuranceIdentifier, + Modifier.testTag(TestTag.Profile.InsuranceId) ) } - } -} -@Composable -fun ProfileConnectedCard(insuranceInformation: ProfilesUseCaseData.ProfileInsuranceInformation) { - - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - backgroundColor = AppTheme.colors.neutral100, - contentColor = AppTheme.colors.neutral999, - elevation = 0.dp, - ) { - val textStyle = AppTheme.typography.body2l - - if (insuranceInformation.insurantName != null && insuranceInformation.insuranceIdentifier != null && insuranceInformation.insuranceName != null) { - Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { - Text( - stringResource( - R.string.profile_connected - ), - style = MaterialTheme.typography.body1 - ) - Text(insuranceInformation.insurantName, style = textStyle) - Text(insuranceInformation.insuranceIdentifier, style = textStyle) - Text(insuranceInformation.insuranceName, style = textStyle) - } + if (ssoTokenScope != null) { + LabeledText( + stringResource(R.string.profile_insurance_information_connected_label), + when (ssoTokenScope) { + is IdpData.DefaultToken -> stringResource( + R.string.profile_insurance_information_connected_health_card + ) + + is IdpData.ExternalAuthenticationToken -> ssoTokenScope.authenticatorName + is IdpData.AlternateAuthenticationToken, + is IdpData.AlternateAuthenticationWithoutToken -> stringResource( + R.string.profile_insurance_information_connected_biometrics + ) + } + ) } else { - Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { - Text( - stringResource(R.string.profile_not_connected), - textAlign = TextAlign.Center, - style = textStyle - ) + ClickableLabeledTextWithIcon( + description = stringResource(R.string.profile_insurance_information_connected_label), + content = stringResource(R.string.profile_insurance_information_not_connected), + icon = Icons.Rounded.Refresh + ) { + onClickLogIn() } } + SpacerLarge() + Divider() + SpacerLarge() } } @Composable -fun createProfileColor(colors: ProfileColorNames): ProfileColor { +fun createProfileColor(colors: ProfilesData.ProfileColorNames): ProfileColor { return profileColor(profileColorNames = colors) } @Composable fun ColorSelector( - profileColorName: ProfileColorNames, + modifier: Modifier, + profileColorName: ProfilesData.ProfileColorNames, selected: Boolean, - onSelectColor: (ProfileColorNames) -> Unit, + onSelectColor: (ProfilesData.ProfileColorNames) -> Unit ) { val colors = createProfileColor(profileColorName) val contentDescription = annotatedStringResource( @@ -631,13 +824,13 @@ fun ColorSelector( ).toString() Surface( - modifier = Modifier - .size(40.dp), - shape = CircleShape, + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .clickable(onClick = { onSelectColor(profileColorName) }), color = colors.backGroundColor ) { Row( - modifier = Modifier.clickable(onClick = { onSelectColor(profileColorName) }), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { @@ -653,47 +846,39 @@ fun ColorSelector( } } +/** + * Shows the given content if != null labeled with a description as described in design guide for ProfileScreen. + */ @Composable -fun RemoveProfileSection(onClickRemoveProfile: () -> Unit) { - var dialogVisible by remember { mutableStateOf(false) } - if (dialogVisible) { - - CommonAlertDialog( - header = stringResource(id = R.string.remove_profile_header), - info = stringResource(R.string.remove_profile_detail_message), - actionText = stringResource(R.string.remove_profile_yes), - cancelText = stringResource(R.string.remove_profile_no), - onCancel = { dialogVisible = false }, - onClickAction = { - onClickRemoveProfile() - dialogVisible = false - } - ) +fun LabeledText(description: String, content: String, modifier: Modifier = Modifier) { + Column(modifier.padding(PaddingDefaults.Medium)) { + Text(content, style = AppTheme.typography.body1) + Text(description, style = AppTheme.typography.body2l) } +} - Button( - onClick = { dialogVisible = true }, - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp, - top = 32.dp, - bottom = 16.dp - ) - .fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.red600, - contentColor = AppTheme.colors.neutral000 - ) +@Composable +fun ClickableLabeledTextWithIcon( + description: String, + content: String, + modifier: Modifier = Modifier, + icon: ImageVector, + onClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { + onClick() + } + .padding(PaddingDefaults.Medium), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - stringResource(R.string.remove_profile).uppercase(Locale.getDefault()), - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 8.dp - ) - ) + Column { + Text(content, style = AppTheme.typography.body1) + Text(description, style = AppTheme.typography.body2l) + } + Icon(icon, contentDescription = null, tint = AppTheme.colors.primary600) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt new file mode 100644 index 00000000..22a51ddf --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/PairedDevices.kt @@ -0,0 +1,490 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.ui + +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.mini.ui.NoneEnrolledException +import de.gematik.ti.erp.app.cardwall.mini.ui.PromptAuthenticator +import de.gematik.ti.erp.app.cardwall.mini.ui.UserNotAuthenticatedException +import de.gematik.ti.erp.app.cardwall.ui.toAnnotatedString +import de.gematik.ti.erp.app.core.LocalAuthenticator +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.idp.usecase.RefreshFlowException +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.settings.ui.SettingsViewModel +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.annotatedStringBold +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.launch +import io.github.aakira.napier.Napier +import java.io.IOException +import java.net.UnknownHostException +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun ProfileEditPairedDeviceSection( + onShowPairedDevices: () -> Unit +) { + SettingsMenuHeadline(stringResource(R.string.settings_paired_devices_title)) + + // connected devices section + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = onShowPairedDevices + ) + .padding(PaddingDefaults.Medium) + .semantics(mergeDescendants = true) {} + ) { + Icon(Icons.Rounded.Devices, null, tint = AppTheme.colors.primary600) + Text(stringResource(R.string.settings_login_connected_devices), style = AppTheme.typography.body1) + } + SpacerLarge() + Divider(modifier = Modifier.padding(horizontal = PaddingDefaults.Medium)) + SpacerLarge() +} + +@Composable +fun PairedDevicesScreen( + selectedProfile: ProfilesUseCaseData.Profile, + settingsViewModel: SettingsViewModel, + onBack: () -> Unit +) { + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.paired_devices_title), + navigationMode = NavigationBarMode.Back, + listState = listState, + onBack = onBack + ) { + PairedDevices( + modifier = Modifier.padding(it), + selectedProfile = selectedProfile, + settingsViewModel = settingsViewModel, + listState = listState + ) + } +} + +@Stable +private sealed interface RefreshState { + @Stable + object Loading : RefreshState + + @Stable + class WithResults(val result: ProfilesUseCaseData.PairedDevices) : RefreshState + + @Stable + object NoResults : RefreshState + + @Stable + class Error(val throwable: Throwable) : RefreshState +} + +@Stable +private sealed interface DeleteState { + @Stable + class Deleting(val device: ProfilesUseCaseData.PairedDevice, val isThisDevice: Boolean) : DeleteState + + @Stable + object None : DeleteState + + @Stable + object Error : DeleteState +} + +// tag::PairedDevicesUI[] +@Composable +private fun PairedDevices( + modifier: Modifier, + selectedProfile: ProfilesUseCaseData.Profile, + settingsViewModel: SettingsViewModel, + listState: LazyListState +) { + val authenticator = LocalAuthenticator.current + + val refreshFlow = remember { MutableSharedFlow() } + var state by remember { mutableStateOf(RefreshState.NoResults) } + LaunchedEffect(selectedProfile) { + refreshFlow + .onStart { emit(Unit) } // emit once to start the flow directly + .collectLatest { + state = RefreshState.Loading + settingsViewModel + .pairedDevices(selectedProfile.id) + .retry(1) { throwable -> + Napier.e("Couldn't get paired devices", throwable) + if (throwable is RefreshFlowException && throwable.userActionRequired) { + authenticator + .authenticateForPairedDevices(selectedProfile.id) + .first() + .let { + when (it) { + PromptAuthenticator.AuthResult.Authenticated -> true + PromptAuthenticator.AuthResult.Cancelled -> false + PromptAuthenticator.AuthResult.NoneEnrolled -> + throw NoneEnrolledException() + PromptAuthenticator.AuthResult.UserNotAuthenticated -> + throw UserNotAuthenticatedException() + } + } + } else { + false + } + } + .catch { + Napier.d("Couldn't get paired devices", it) + + state = RefreshState.Error(it) + } + .collect { + state = if (it.devices.isEmpty()) { + RefreshState.NoResults + } else { + RefreshState.WithResults(it) + } + } + } + } + + val keyStoreAlias = remember(selectedProfile) { + (selectedProfile.ssoTokenScope as? IdpData.TokenWithKeyStoreAliasScope) + ?.aliasOfSecureElementEntryBase64() + } + + val mutex = MutatorMutex() + val coroutineScope = rememberCoroutineScope() + + var deleteState by remember(state) { mutableStateOf(null) } + + (deleteState as? DeleteState.Deleting)?.let { + DeleteDeviceDialog( + device = it.device, + isThisDevice = it.isThisDevice, + onCancel = { + deleteState = DeleteState.None + }, + onClickAction = { + coroutineScope.launch { + mutex.mutate { + settingsViewModel + .deletePairedDevice(selectedProfile.id, it.device) + .onFailure { + deleteState = DeleteState.Error + } + .onSuccess { + deleteState = DeleteState.None + } + + // no matter if we received an error or not, we need to refresh this list + refreshFlow.emit(Unit) + } + } + } + ) + } + + LazyColumn( + state = listState, + modifier = modifier + ) { + when (state) { + RefreshState.Loading -> item { EmptyScreenLoading(Modifier.fillParentMaxSize()) } + RefreshState.NoResults -> item { EmptyScreenNoDevices(Modifier.fillParentMaxSize()) } + is RefreshState.Error -> item { + val (title, desc) = errorMessageFromException((state as RefreshState.Error).throwable) + EmptyScreenFailure( + modifier = Modifier.fillParentMaxSize(), + title = title, + description = desc, + onClickRetry = { + coroutineScope.launch { + refreshFlow.emit(Unit) + } + } + ) + } + is RefreshState.WithResults -> { + items((state as RefreshState.WithResults).result.devices) { device: ProfilesUseCaseData.PairedDevice -> + val isThisDevice = keyStoreAlias?.let { + device.isOurDevice(it) + } ?: false + PairedDevice( + device = device, + isOurDevice = isThisDevice, + onDeleteDevice = { + deleteState = DeleteState.Deleting(device, isThisDevice) + } + ) + } + } + } + } +} +// end::PairedDevicesUI[] + +@Composable +private fun DeleteDeviceDialog( + device: ProfilesUseCaseData.PairedDevice, + isThisDevice: Boolean, + onCancel: () -> Unit, + onClickAction: () -> Unit +) { + if (isThisDevice) { + DeleteThisDeviceDialog( + device = device, + onCancel = onCancel, + onClickAction = onClickAction + ) + } else { + DeleteOtherDeviceDialog( + device = device, + onCancel = onCancel, + onClickAction = onClickAction + ) + } +} + +@Composable +private fun DeleteOtherDeviceDialog( + device: ProfilesUseCaseData.PairedDevice, + onCancel: () -> Unit, + onClickAction: () -> Unit +) { + CommonAlertDialog( + header = stringResource(R.string.paired_devices_delete_title).toAnnotatedString(), + info = annotatedStringResource(R.string.paired_devices_delete_description, annotatedStringBold(device.name)), + cancelText = stringResource(R.string.paired_devices_delete_cancel), + actionText = stringResource(R.string.paired_devices_delete_remove), + onCancel = onCancel, + onClickAction = onClickAction + ) +} + +@Composable +private fun DeleteThisDeviceDialog( + device: ProfilesUseCaseData.PairedDevice, + onCancel: () -> Unit, + onClickAction: () -> Unit +) { + CommonAlertDialog( + header = stringResource(R.string.paired_devices_delete_this_title).toAnnotatedString(), + info = annotatedStringResource( + R.string.paired_devices_delete_this_description, + annotatedStringBold(device.name) + ), + cancelText = stringResource(R.string.paired_devices_delete_cancel), + actionText = stringResource(R.string.paired_devices_delete_remove), + onCancel = onCancel, + onClickAction = onClickAction + ) +} + +@Composable +private fun EmptyScreenLoading(modifier: Modifier) { + EmptyScreen(modifier) { + CircularProgressIndicator(Modifier.size(48.dp)) + Text( + stringResource(R.string.paired_devices_loading_description), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun EmptyScreenNoDevices(modifier: Modifier) { + EmptyScreen(modifier) { + Text( + stringResource(R.string.paired_devices_no_devices_title), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + SpacerSmall() + Text( + stringResource(R.string.paired_devices_no_devices_description), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun EmptyScreenFailure( + modifier: Modifier, + title: String, + description: String, + onClickRetry: () -> Unit +) { + EmptyScreen(modifier) { + Text( + title, + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + Text( + description, + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + TextButton(onClick = onClickRetry) { + Icon(Icons.Rounded.Refresh, null) + SpacerSmall() + Text(stringResource(R.string.paired_devices_error_retry)) + } + } +} + +@Composable +private fun EmptyScreen( + modifier: Modifier, + content: @Composable () -> Unit +) { + Box(modifier) { + Column( + modifier = Modifier + .align(BiasAlignment(0f, -0.33f)) + .padding(PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + content() + } + } +} + +@Composable +private fun PairedDevice( + device: ProfilesUseCaseData.PairedDevice, + isOurDevice: Boolean, + onDeleteDevice: () -> Unit +) { + Row(Modifier.padding(PaddingDefaults.Medium)) { + Column(Modifier.weight(1f)) { + Text(device.name, style = AppTheme.typography.body1) + val connectedOn = localizedDateString(device.connectedOn) + if (isOurDevice) { + Text( + stringResource(R.string.paired_device_subtitle_our_device, connectedOn), + style = AppTheme.typography.body2l + ) + } else { + Text(stringResource(R.string.paired_device_subtitle, connectedOn), style = AppTheme.typography.body2l) + } + } + SpacerMedium() + IconButton(onClick = onDeleteDevice) { + Icon(Icons.Rounded.Delete, null, tint = AppTheme.colors.neutral500) + } + } +} + +@Composable +fun localizedDateTimeString(timestamp: Instant, format: FormatStyle = FormatStyle.LONG): String { + val config = LocalConfiguration.current + return remember(config, format) { + val fmt = DateTimeFormatter.ofLocalizedDateTime(format) + LocalDateTime.ofInstant(timestamp, ZoneId.systemDefault()).format(fmt) + } +} + +@Composable +fun localizedDateString(timestamp: Instant, format: FormatStyle = FormatStyle.LONG): String { + val config = LocalConfiguration.current + return remember(config, format) { + val fmt = DateTimeFormatter.ofLocalizedDate(format) + LocalDateTime.ofInstant(timestamp, ZoneId.systemDefault()).toLocalDate().format(fmt) + } +} + +@Composable +fun errorMessageFromException(t: Throwable): Pair { + val other = stringResource(R.string.paired_devices_error_generic_title) to + stringResource(R.string.paired_devices_error_generic_description) + val network = stringResource(R.string.paired_devices_error_no_network_title) to + stringResource(R.string.paired_devices_error_no_network_description) + + return when (t) { + is IOException -> when { + t.cause is UnknownHostException -> network + else -> other + } + else -> other + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColorAndImagePicker.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColorAndImagePicker.kt new file mode 100644 index 00000000..6d066a01 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColorAndImagePicker.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material.icons.rounded.AddAPhoto +import androidx.compose.material.icons.rounded.PersonOutline +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar +import de.gematik.ti.erp.app.utils.compose.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerXXLarge + +@SuppressLint("UnusedMaterialScaffoldPaddingParameter") +@Composable +fun ProfileColorAndImagePicker( + selectedProfile: ProfilesUseCaseData.Profile, + clearPersonalizedImage: () -> Unit, + onPickPersonalizedImage: () -> Unit, + onBack: () -> Unit, + onSelectAvatar: (ProfilesData.AvatarFigure) -> Unit, + onSelectProfileColor: (ProfilesData.ProfileColorNames) -> Unit +) { + val listState = rememberLazyListState() + + Scaffold( + modifier = Modifier.imePadding(), + topBar = { + NavigationTopAppBar( + navigationMode = NavigationBarMode.Back, + title = stringResource(R.string.edit_profile_picture), + onBack = onBack, + actions = {} + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + item { + SpacerMedium() + ProfileImage(selectedProfile) { + clearPersonalizedImage() + } + } + item { + SpacerXXLarge() + AvatarPicker( + profile = selectedProfile, + currentAvatarFigure = selectedProfile.avatarFigure, + onPickPersonalizedImage = onPickPersonalizedImage, + onSelectAvatar = onSelectAvatar + ) + } + + if (selectedProfile.avatarFigure != ProfilesData.AvatarFigure.PersonalizedImage) { + item { + SpacerXXLarge() + SpacerMedium() + Text( + stringResource(R.string.edit_profile_background_color), + style = AppTheme.typography.h6 + ) + SpacerLarge() + ColorPicker(selectedProfile.color, onSelectProfileColor) + } + } + } + } +} + +@Composable +fun AvatarPicker( + profile: ProfilesUseCaseData.Profile, + currentAvatarFigure: ProfilesData.AvatarFigure, + onPickPersonalizedImage: () -> Unit, + onSelectAvatar: (ProfilesData.AvatarFigure) -> Unit +) { + val listState = rememberLazyListState() + + LazyRow( + state = listState, + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium) + ) { + ProfilesData.AvatarFigure.values().forEach { figure -> + item { + AvatarSelector( + figure = figure, + profile = profile, + selected = figure == currentAvatarFigure, + onPickPersonalizedImage = onPickPersonalizedImage, + onSelectAvatar = onSelectAvatar + ) + } + } + } +} + +@Composable +fun AvatarSelector( + profile: ProfilesUseCaseData.Profile, + figure: ProfilesData.AvatarFigure, + selected: Boolean, + onPickPersonalizedImage: () -> Unit, + onSelectAvatar: (ProfilesData.AvatarFigure) -> Unit +) { + Surface( + modifier = Modifier + .size(80.dp), + shape = CircleShape, + border = if (selected) { + BorderStroke(5.dp, color = AppTheme.colors.primary600) + } else if (figure != ProfilesData.AvatarFigure.PersonalizedImage) { + BorderStroke(1.dp, color = AppTheme.colors.neutral300) + } else { + null + } + ) { + Row( + modifier = Modifier + .background( + color = when (figure) { + ProfilesData.AvatarFigure.PersonalizedImage -> { + AppTheme.colors.neutral100 + } + + else -> { + AppTheme.colors.neutral025 + } + } + ) + .clickable(onClick = { + if (figure == ProfilesData.AvatarFigure.PersonalizedImage) { + onPickPersonalizedImage() + onSelectAvatar(figure) + } + onSelectAvatar(figure) + }), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + ChooseAvatar( + useSmallImages = true, + emptyIcon = Icons.Rounded.AddAPhoto, + iconModifier = Modifier.size(24.dp), + profile = profile, + figure = figure + ) + } + } +} + +@Composable +fun ColorPicker( + profileColorName: ProfilesData.ProfileColorNames, + onSelectProfileColor: (ProfilesData.ProfileColorNames) -> Unit +) { + val currentSelectedColors = profileColor(profileColorNames = profileColorName) + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + ProfilesData.ProfileColorNames.values().forEach { + val currentValueColors = profileColor(profileColorNames = it) + ColorSelector( + modifier = Modifier.testTag( + when (it) { + ProfilesData.ProfileColorNames.SPRING_GRAY -> + TestTag.Profile.EditProfileIcon.ColorSelectorSpringGrayButton + ProfilesData.ProfileColorNames.SUN_DEW -> + TestTag.Profile.EditProfileIcon.ColorSelectorSunDewButton + ProfilesData.ProfileColorNames.PINK -> + TestTag.Profile.EditProfileIcon.ColorSelectorPinkButton + ProfilesData.ProfileColorNames.TREE -> + TestTag.Profile.EditProfileIcon.ColorSelectorTreeButton + ProfilesData.ProfileColorNames.BLUE_MOON -> + TestTag.Profile.EditProfileIcon.ColorSelectorBlueMoonButton + } + ), + profileColorName = it, + selected = currentValueColors == currentSelectedColors, + onSelectColor = onSelectProfileColor + ) + } + } + SpacerMedium() + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text( + currentSelectedColors.colorName, + style = AppTheme.typography.body2l + ) + } + } +} + +@Composable +fun ProfileImage(selectedProfile: ProfilesUseCaseData.Profile, onClickDeleteAvatar: () -> Unit) { + val colors = profileColor(profileColorNames = selectedProfile.color) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) + .semantics(true) { + stateDescription = selectedProfile.color.name + } + ) { + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Box( + modifier = Modifier + .size(160.dp) + .clip(CircleShape) + .aspectRatio(1f) + .background(colors.backGroundColor), + contentAlignment = Alignment.Center + ) { + ChooseAvatar( + iconModifier = Modifier.size(36.dp), + profile = selectedProfile, + emptyIcon = Icons.Rounded.PersonOutline, + figure = selectedProfile.avatarFigure, + showPersonalizedImage = selectedProfile.personalizedImage != null + ) + } + if (!(selectedProfile.hasNoImageSelected())) { + @Suppress("MagicNumber") + Box( + modifier = Modifier + .size(32.dp) + .align(Alignment.TopEnd) + .offset((-8).dp, 4.dp) + .clip(CircleShape) + .aspectRatio(1f) + .background(AppTheme.colors.neutral050) + .border(1.dp, color = AppTheme.colors.neutral000, shape = RoundedCornerShape(16.dp)) + ) { + IconButton(onClick = onClickDeleteAvatar) { + Icon( + imageVector = Icons.Rounded.Close, + tint = AppTheme.colors.neutral600, + contentDescription = null + ) + } + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColors.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColors.kt index a1f93173..bd1a95e8 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColors.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColors.kt @@ -23,42 +23,41 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.model.ProfilesData import de.gematik.ti.erp.app.theme.AppTheme @Immutable data class ProfileColor(val textColor: Color, val colorName: String, val backGroundColor: Color, val borderColor: Color) @Composable -fun profileColor(profileColorNames: ProfileColorNames): ProfileColor { - +fun profileColor(profileColorNames: ProfilesData.ProfileColorNames): ProfileColor { return when (profileColorNames) { - ProfileColorNames.SPRING_GRAY -> ProfileColor( + ProfilesData.ProfileColorNames.SPRING_GRAY -> ProfileColor( textColor = AppTheme.colors.neutral700, colorName = stringResource(R.string.profile_color_name_gray), backGroundColor = AppTheme.colors.neutral200, - borderColor = AppTheme.colors.neutral400, + borderColor = AppTheme.colors.neutral400 ) - ProfileColorNames.SUN_DEW -> ProfileColor( + ProfilesData.ProfileColorNames.SUN_DEW -> ProfileColor( textColor = AppTheme.colors.yellow700, colorName = stringResource(R.string.profile_color_sun_dew), backGroundColor = AppTheme.colors.yellow200, borderColor = AppTheme.colors.yellow400 ) - ProfileColorNames.PINK -> ProfileColor( + ProfilesData.ProfileColorNames.PINK -> ProfileColor( textColor = AppTheme.colors.red700, colorName = stringResource(R.string.profile_color_name_pink), backGroundColor = AppTheme.colors.red200, borderColor = AppTheme.colors.red400 ) - ProfileColorNames.TREE -> ProfileColor( + ProfilesData.ProfileColorNames.TREE -> ProfileColor( textColor = AppTheme.colors.green700, colorName = stringResource(R.string.profile_color_name_tree), backGroundColor = AppTheme.colors.green200, borderColor = AppTheme.colors.green400 ) - ProfileColorNames.BLUE_MOON -> ProfileColor( + ProfilesData.ProfileColorNames.BLUE_MOON -> ProfileColor( textColor = AppTheme.colors.primary700, colorName = stringResource(R.string.profile_color_name_moon), backGroundColor = AppTheme.colors.primary200, @@ -68,7 +67,7 @@ fun profileColor(profileColorNames: ProfileColorNames): ProfileColor { } @Composable -fun connectionTextColor(profileSsoToken: SingleSignOnToken?) = if (profileSsoToken?.isValid() == true) { +fun connectionTextColor(profileSsoToken: IdpData.SingleSignOnToken?) = if (profileSsoToken?.isValid() == true) { AppTheme.colors.green600 } else { AppTheme.colors.neutral600 diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt new file mode 100644 index 00000000..299974f0 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileHandler.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import org.kodein.di.compose.rememberViewModel + +interface ProfileBridge { + val profiles: Flow> + suspend fun switchActiveProfile(profile: ProfilesUseCaseData.Profile) +} + +class ProfileViewModel( + private val profilesUseCase: ProfilesUseCase +) : ViewModel(), ProfileBridge { + override val profiles: Flow> = + profilesUseCase.profiles + + override suspend fun switchActiveProfile(profile: ProfilesUseCaseData.Profile) { + profilesUseCase.switchActiveProfile(profile) + } +} + +val DefaultProfile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + lastAuthenticated = null, + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage +) + +@Stable +class ProfileHandler( + private val bridge: ProfileBridge, + coroutineScope: CoroutineScope +) { + enum class ProfileConnectionState { + LoggedIn, + LoggedOutWithoutTokenBiometrics, + LoggedOutWithoutToken, + LoggedOut, + NeverConnected + } + + private fun ProfilesUseCaseData.Profile.neverConnected() = ssoTokenScope == null && lastAuthenticated == null + + private fun ProfilesUseCaseData.Profile.ssoTokenSetAndConnected() = + ssoTokenScope?.token != null && ssoTokenScope.token?.isValid() == true + + private fun ProfilesUseCaseData.Profile.ssoTokenSetAndDisconnected() = + ssoTokenScope != null && ssoTokenScope.token?.isValid() == false || + lastAuthenticated != null + + private fun ProfilesUseCaseData.Profile.ssoTokenNotSet() = + when (ssoTokenScope) { + is IdpData.ExternalAuthenticationToken, + is IdpData.AlternateAuthenticationToken, + is IdpData.AlternateAuthenticationWithoutToken, + is IdpData.DefaultToken -> ssoTokenScope.token == null + null -> true + } + + private fun ProfilesUseCaseData.Profile.ssoTokenWithoutScope() = + when (ssoTokenScope) { + is IdpData.AlternateAuthenticationWithoutToken -> true + else -> false + } + + @Stable + fun connectionState(profile: ProfilesUseCaseData.Profile): ProfileConnectionState? = + when { + profile.neverConnected() -> + ProfileConnectionState.NeverConnected + profile.ssoTokenWithoutScope() -> + ProfileConnectionState.LoggedOutWithoutTokenBiometrics + profile.ssoTokenNotSet() -> + ProfileConnectionState.LoggedOutWithoutToken + profile.ssoTokenSetAndConnected() -> + ProfileConnectionState.LoggedIn + profile.ssoTokenSetAndDisconnected() -> + ProfileConnectionState.LoggedOut + else -> null + } + + var activeProfile by mutableStateOf(DefaultProfile) + private set + + private var profilesFlow = + bridge + .profiles + .onEach { + activeProfile = it.find { it.active } ?: DefaultProfile + } + .shareIn(coroutineScope, SharingStarted.Eagerly, 1) + + val profiles: State> + @Composable + get() = profilesFlow.collectAsState(emptyList()) + + suspend fun switchActiveProfile(profile: ProfilesUseCaseData.Profile) { + bridge.switchActiveProfile(profile) + } +} + +@Composable +fun rememberProfileHandler(): ProfileHandler { + val profileViewModel by rememberViewModel() + val coroutineScope = rememberCoroutineScope() + return remember { + ProfileHandler(profileViewModel, coroutineScope) + } +} + +val LocalProfileHandler = + staticCompositionLocalOf { error("No profile state provided!") } diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileImageCropper.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileImageCropper.kt new file mode 100644 index 00000000..0c852cb8 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileImageCropper.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.ui + +import android.Manifest +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.updateLayoutParams +import com.canhub.cropper.CropImageView +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar + +const val CROPPED_IMAGE_SIZE = 256 +const val IMAGE_ALPHA = 0.8f + +@Composable +fun ProfileImageCropper(onSaveCroppedImage: (Bitmap) -> Unit, onBack: () -> Unit) { + val context = LocalContext.current + val cropView = remember { + CropImageView(context).apply { + isAutoZoomEnabled = false + cropShape = CropImageView.CropShape.OVAL + setFixedAspectRatio(true) + } + } + + var readStoragePermissionGranted by rememberSaveable { mutableStateOf(false) } + val readStoragePermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + readStoragePermissionGranted = it + } + + val readStoragePermissionRequired = + Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q + + LaunchedEffect(readStoragePermissionRequired, readStoragePermissionGranted) { + if (readStoragePermissionRequired && !readStoragePermissionGranted) { + readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + NavigationTopAppBar( + navigationMode = NavigationBarMode.Back, + title = "", + onBack = onBack, + actions = { + TextButton(onClick = { + cropView.getCroppedImage(reqWidth = CROPPED_IMAGE_SIZE, reqHeight = CROPPED_IMAGE_SIZE)?.let { + onSaveCroppedImage(it) + } + }) { + Text(text = stringResource(R.string.image_crop_save_image)) + } + } + ) + }, + backgroundColor = Color.Black + ) { + var background: Bitmap? by remember { mutableStateOf(null) } + val imagePickerLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? -> + uri?.let { + background = getOriginalBitMap(context, uri) + cropView.setImageBitmap(background) + } ?: run { onBack() } + } + + LaunchedEffect(Unit) { + imagePickerLauncher.launch("image/*") + } + + BoxWithConstraints(Modifier.fillMaxSize()) { + background?.let { + Image( + bitmap = it.asImageBitmap(), + contentScale = ContentScale.Crop, + contentDescription = null, + modifier = Modifier + .blur(12.dp) + .alpha(IMAGE_ALPHA) + .fillMaxSize() + ) + } + + val width = with(LocalDensity.current) { + this@BoxWithConstraints.maxWidth.roundToPx() + } + val height = with(LocalDensity.current) { + this@BoxWithConstraints.maxHeight.roundToPx() + } + AndroidView( + factory = { + cropView + } + ) { + it.updateLayoutParams { + this.height = height + this.width = width + } + } + } + } +} + +fun getOriginalBitMap(context: Context, imageUri: Uri): Bitmap { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + @Suppress("DEPRECATION") + return MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri) + } else { + val source = ImageDecoder.createSource(context.contentResolver, imageUri) + return ImageDecoder.decodeBitmap(source) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileSettingsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileSettingsViewModel.kt new file mode 100644 index 00000000..96510c31 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileSettingsViewModel.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.ui + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.ProfileAvatarUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData + +import kotlinx.coroutines.launch + +class ProfileSettingsViewModel( + private val profilesUseCase: ProfilesUseCase, + private val profileAvatarUseCase: ProfileAvatarUseCase +) : ViewModel() { + + fun updateProfileColor(profile: ProfilesUseCaseData.Profile, color: ProfilesData.ProfileColorNames) { + viewModelScope.launch { + profilesUseCase.updateProfileColor(profile, color) + } + } + + fun savePersonalizedProfileImage(profileId: ProfileIdentifier, profileImage: Bitmap) { + viewModelScope.launch { + profileAvatarUseCase.savePersonalizedProfileImage(profileId, profileImage) + } + } + + fun updateProfileName(profileId: ProfileIdentifier, newName: String) { + viewModelScope.launch { + profilesUseCase.updateProfileName(profileId, newName) + } + } + + fun saveAvatarFigure(profileId: ProfileIdentifier, avatarFigure: ProfilesData.AvatarFigure) { + viewModelScope.launch { + profileAvatarUseCase.saveAvatarFigure(profileId, avatarFigure) + } + } + + fun clearPersonalizedImage(profileId: ProfileIdentifier) { + viewModelScope.launch { + profileAvatarUseCase.clearPersonalizedImage(profileId) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileStringRessource.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileStringRessource.kt index 18ffa964..036b0133 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileStringRessource.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileStringRessource.kt @@ -21,11 +21,11 @@ package de.gematik.ti.erp.app.profiles.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken +import de.gematik.ti.erp.app.idp.model.IdpData @Composable fun connectionText( - ssoToken: SingleSignOnToken?, + ssoToken: IdpData.SingleSignOnToken?, lastAuthenticatedDate: String? ) = when { ssoToken != null && ssoToken.isValid() -> { diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfileAvatarUsecase.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfileAvatarUsecase.kt new file mode 100644 index 00000000..07098c46 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfileAvatarUsecase.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.usecase + +import android.graphics.Bitmap +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +private const val BitmapQuality = 100 + +class ProfileAvatarUseCase( + private val profilesRepository: ProfilesRepository, + private val dispatcher: DispatchProvider +) { + suspend fun saveAvatarFigure(profileId: ProfileIdentifier, avatarFigure: ProfilesData.AvatarFigure) { + withContext(dispatcher.IO) { + profilesRepository.saveAvatarFigure(profileId, avatarFigure) + } + } + + suspend fun savePersonalizedProfileImage(profileId: ProfileIdentifier, profileImage: Bitmap) { + withContext(dispatcher.IO) { + val outputStream = ByteArrayOutputStream() + profileImage.compress(Bitmap.CompressFormat.PNG, BitmapQuality, outputStream) + val byteArray: ByteArray = outputStream.toByteArray() + profilesRepository.savePersonalizedProfileImage(profileId, byteArray) + } + } + + suspend fun clearPersonalizedImage(profileId: ProfileIdentifier) { + withContext(dispatcher.IO) { + profilesRepository.clearPersonalizedProfileImage(profileId) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCase.kt index e46f0e80..84909d6d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCase.kt @@ -18,107 +18,82 @@ package de.gematik.ti.erp.app.profiles.usecase -import androidx.paging.PagingData -import androidx.paging.map -import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.db.entities.ProfileColorNames +import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken.AlternateAuthenticationWithoutToken +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import de.gematik.ti.erp.app.settings.usecase.DEFAULT_PROFILE_NAME -import java.time.Instant -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview +import de.gematik.ti.erp.app.protocol.repository.AuditEventsRepository import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.transformLatest -@OptIn(ExperimentalCoroutinesApi::class) -@Singleton -class ProfilesUseCase @Inject constructor( +fun List.activeProfile() = + find { profile -> profile.active }!! + +class ProfilesUseCase( private val profilesRepository: ProfilesRepository, private val idpRepository: IdpRepository, - dispatchProvider: DispatchProvider + private val auditRepository: AuditEventsRepository ) { - @OptIn(FlowPreview::class) - val profiles: Flow> = - profilesRepository.activeProfile().filterNotNull().flatMapLatest { activeProfile -> - profilesRepository.profiles().transformLatest { profiles -> - val profileFlows = profiles - .map { profile -> - combine( - idpRepository.getSingleSignOnToken(profile.name), - idpRepository.decryptedAccessToken(profile.name) - ) { ssoToken, accessToken -> - val active = activeProfile.profileName == profile.name - ProfilesUseCaseData.Profile( - profile.id, - profile.name, - ProfilesUseCaseData.ProfileInsuranceInformation( - profile.insurantName, - profile.insuranceIdentifier, - profile.insuranceName - ), - active, - profile.color, - profile.lastAuthenticated, - ssoToken = ssoToken, - accessToken = accessToken, - ) - } - } - - emitAll(combine(profileFlows) { it.toList() }) + val profiles: Flow> + get() = profilesRepository.profiles().map { profiles -> + profiles.map { profile -> + ProfilesUseCaseData.Profile( + id = profile.id, + name = profile.name, + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation.ofNullable( + profile.insurantName, + profile.insuranceIdentifier, + profile.insuranceName + ), + active = profile.active, + color = profile.color, + avatarFigure = profile.avatarFigure, + personalizedImage = profile.personalizedImage, + lastAuthenticated = profile.lastAuthenticated, + ssoTokenScope = profile.singleSignOnTokenScope + ) } } .distinctUntilChanged() - .shareIn( - CoroutineScope(dispatchProvider.default()), - SharingStarted.Lazily, - 1 - ) .onEach { profiles -> - profiles.forEach { - if (it.ssoToken != null && - it.ssoToken !is AlternateAuthenticationWithoutToken && - it.lastAuthenticated == null + profiles.forEach { profile -> + if (profile.ssoTokenScope != null && + profile.ssoTokenScope !is IdpData.AlternateAuthenticationWithoutToken && + profile.lastAuthenticated == null ) { - updateLastAuthenticated(it.ssoToken.validOn, it.name) + profile.ssoTokenScope.token?.let { token -> + profilesRepository.updateLastAuthenticated(profile.id, token.validOn) + } } } } - private suspend fun updateLastAuthenticated(validOn: Instant, profileName: String) = - profilesRepository.updateLastAuthenticated(validOn, profileName) + val activeProfile: Flow = + profiles.map { it.activeProfile() } - suspend fun addProfile(profileName: String, activate: Boolean = false) { - if (profileName.isNotBlank()) { + fun decryptedAccessToken(profileId: ProfileIdentifier): Flow = + idpRepository.decryptedAccessToken(profileId) + + suspend fun addProfile(newProfileName: String, activate: Boolean = false) { + sanitizedProfileName(newProfileName)?.also { profileName -> profilesRepository.saveProfile(profileName.trim(), activate = activate) - } + } ?: error("invalid profile name `$newProfileName`") } /** * Removes the [profile] and adds a new profile with the name set to [newProfileName]. */ - suspend fun removeProfile(profile: ProfilesUseCaseData.Profile, newProfileName: String) { + suspend fun removeAndSaveProfile(profile: ProfilesUseCaseData.Profile, newProfileName: String) { addProfile(newProfileName, activate = true) idpRepository.invalidateDecryptedAccessToken(profile.name) - profilesRepository.removeProfile(profile.name) + profilesRepository.removeProfile(profile.id) } /** @@ -126,62 +101,39 @@ class ProfilesUseCase @Inject constructor( */ suspend fun removeProfile(profile: ProfilesUseCaseData.Profile) { idpRepository.invalidateDecryptedAccessToken(profile.name) - profilesRepository.removeProfile(profile.name) + profilesRepository.removeProfile(profile.id) } suspend fun logout(profile: ProfilesUseCaseData.Profile) { - idpRepository.invalidateWithUserCredentials(profile.name) - } - - fun isProfileSetupCompleted() = - activeProfileName().map { - it != DEFAULT_PROFILE_NAME - } - - suspend fun overwriteDefaultProfileName(newProfileName: String) { - profilesRepository.updateProfileByName(DEFAULT_PROFILE_NAME, newProfileName.trim(), activate = true) + idpRepository.invalidate(profile.id) } - fun isCanAvailable(profile: ProfilesUseCaseData.Profile) = - idpRepository.cardAccessNumber(profile.name) - .map { can -> - can != null - } - - suspend fun updateProfileName(profile: ProfilesUseCaseData.Profile, newProfileName: String) { - val trimmedName = newProfileName.trim() - if (trimmedName.isNotEmpty() && profile.name != trimmedName) { - idpRepository.updateDecryptedAccessTokenMap(profile.name, trimmedName) - profilesRepository.updateProfileByName(profile.name, trimmedName) - } + suspend fun updateProfileName(profileId: ProfileIdentifier, newProfileName: String) { + sanitizedProfileName(newProfileName)?.also { profileName -> + profilesRepository.updateProfileName(profileId, profileName) + } ?: error("invalid profile name `$newProfileName`") } - suspend fun updateProfileColor(profile: ProfilesUseCaseData.Profile, color: ProfileColorNames) { - profilesRepository.updateProfileColor(profile.name, color) + suspend fun updateProfileColor(profile: ProfilesUseCaseData.Profile, color: ProfilesData.ProfileColorNames) { + profilesRepository.updateProfileColor(profile.id, color) } + // tag::SwitchActiveProfileUseCase[] suspend fun switchActiveProfile(profile: ProfilesUseCaseData.Profile) { - profilesRepository.saveProfile(profile.name, activate = true) + profilesRepository.activateProfile(profile.id) } + // end::SwitchActiveProfileUseCase[] - fun activeProfileName() = activeProfile().map { it.profileName } - - fun activeProfile() = profilesRepository.activeProfile() - - fun getProfileById(profileId: Int) = profilesRepository.getProfileById(profileId) + fun activeProfileId() = activeProfile().mapNotNull { it!!.id } - suspend fun anyProfileAuthenticated() = profiles.first().any { - it.lastAuthenticated != null + fun activeProfile() = profilesRepository.profiles().map { + it.find { profile -> + profile.active + } } - fun loadAuditEventsForProfile(profileName: String): Flow> = - profilesRepository.loadAuditEventsForProfile(profileName).map { - it.map { auditEvent -> - ProfilesUseCaseData.AuditEvent( - text = auditEvent.text, - timeStamp = auditEvent.timestamp, - medicationText = auditEvent.medicationText - ) - } - } + fun auditEvents(profileId: ProfileIdentifier) = auditRepository.auditEvents(profileId) } + +fun sanitizedProfileName(profileName: String): String? = + if (profileName.isNotBlank()) profileName.trim() else null diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesWithPairedDevicesUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesWithPairedDevicesUseCase.kt new file mode 100644 index 00000000..bc261177 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesWithPairedDevicesUseCase.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.usecase + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.time.Instant + +class ProfilesWithPairedDevicesUseCase( + private val idpUseCase: IdpUseCase, + private val dispatchers: DispatchProvider +) { + fun pairedDevices(profileId: ProfileIdentifier): Flow = + flow { + emit( + ProfilesUseCaseData.PairedDevices( + idpUseCase.getPairedDevices(profileId).getOrThrow().map { (raw, pairingData) -> + ProfilesUseCaseData.PairedDevice( + name = raw.name, + alias = pairingData.keyAliasOfSecureElement, + connectedOn = Instant.ofEpochSecond(raw.creationTime) + ) + }.sortedByDescending { + it.connectedOn + } + ) + ) + }.flowOn(dispatchers.IO) + + suspend fun deletePairedDevices( + profileId: ProfileIdentifier, + device: ProfilesUseCaseData.PairedDevice + ): Result = + idpUseCase.deletePairedDevice(profileId = profileId, deviceAlias = device.alias).map { + device.name + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/model/ProfilesUseCaseData.kt b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/model/ProfilesUseCaseData.kt index c67afc1c..5394e8af 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/model/ProfilesUseCaseData.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/usecase/model/ProfilesUseCaseData.kt @@ -19,41 +19,93 @@ package de.gematik.ti.erp.app.profiles.usecase.model import androidx.compose.runtime.Immutable -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken +import androidx.compose.runtime.Stable +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import java.time.Instant -import java.time.OffsetDateTime object ProfilesUseCaseData { - data class ProfileInsuranceInformation( - val insurantName: String? = null, - val insuranceIdentifier: String? = null, - val insuranceName: String? = null, - ) + val insurantName: String = "", + val insuranceIdentifier: String = "", + val insuranceName: String = "" + ) { + companion object { + fun ofNullable( + insurantName: String?, + insuranceIdentifier: String?, + insuranceName: String? + ): ProfileInsuranceInformation { + return ProfileInsuranceInformation(insurantName ?: "", insuranceIdentifier ?: "", insuranceName ?: "") + } + } + } @Immutable data class Profile( - val id: Int, + val id: ProfileIdentifier, val name: String, val insuranceInformation: ProfileInsuranceInformation, val active: Boolean, - val color: ProfileColorNames, + val color: ProfilesData.ProfileColorNames, + val avatarFigure: ProfilesData.AvatarFigure, + val personalizedImage: ByteArray? = null, val lastAuthenticated: Instant? = null, - val ssoToken: SingleSignOnToken? = null, - val accessToken: String? = null + val ssoTokenScope: IdpData.SingleSignOnTokenScope? + ) { + fun ssoTokenValid(now: Instant = Instant.now()) = ssoTokenScope?.token?.isValid(now) ?: false + fun hasNoImageSelected() = this.avatarFigure == ProfilesData.AvatarFigure.PersonalizedImage && + this.personalizedImage == null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Profile + + if (id != other.id) return false + if (name != other.name) return false + if (insuranceInformation != other.insuranceInformation) return false + if (active != other.active) return false + if (color != other.color) return false + if (avatarFigure != other.avatarFigure) return false + if (personalizedImage != null) { + if (other.personalizedImage == null) return false + if (!personalizedImage.contentEquals(other.personalizedImage)) return false + } else if (other.personalizedImage != null) return false + if (lastAuthenticated != other.lastAuthenticated) return false + if (ssoTokenScope != other.ssoTokenScope) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + insuranceInformation.hashCode() + result = 31 * result + active.hashCode() + result = 31 * result + color.hashCode() + result = 31 * result + avatarFigure.hashCode() + result = 31 * result + (personalizedImage?.contentHashCode() ?: 0) + result = 31 * result + (lastAuthenticated?.hashCode() ?: 0) + result = 31 * result + (ssoTokenScope?.hashCode() ?: 0) + return result + } + } + + @Immutable + data class PairedDevice( + val name: String, + val alias: String, + val connectedOn: Instant ) { - fun ssoTokenValid(now: Instant = Instant.now()) = ssoToken?.isValid(now) ?: false - fun connected(): Boolean = - insuranceInformation.insurantName != null && - insuranceInformation.insuranceIdentifier != null && - insuranceInformation.insuranceName != null + @Stable + fun isOurDevice(alias: String) = this.alias == alias } @Immutable - data class AuditEvent( - val text: String, - val medicationText: String?, - val timeStamp: OffsetDateTime, + data class PairedDevices( + val devices: List ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemComponents.kt index 3ea6257a..18db12ae 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemComponents.kt @@ -43,7 +43,6 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton -import de.gematik.ti.erp.app.utils.compose.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.ArrowForward @@ -73,29 +72,30 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel + import androidx.navigation.NavController import com.google.zxing.common.BitMatrix import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.core.LocalActivity import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults import de.gematik.ti.erp.app.utils.compose.AcceptDialog import de.gematik.ti.erp.app.utils.compose.BackInterceptor import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog import de.gematik.ti.erp.app.utils.compose.NavigationClose import de.gematik.ti.erp.app.utils.compose.Spacer8 +import de.gematik.ti.erp.app.utils.compose.TopAppBar import de.gematik.ti.erp.app.utils.compose.annotatedStringBold import de.gematik.ti.erp.app.utils.compose.annotatedStringResource -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel import kotlin.math.max import kotlin.math.roundToInt @OptIn(ExperimentalMaterialApi::class) @Composable -fun RedeemScreen(taskIds: List, navController: NavController, redeemVM: RedeemViewModel = hiltViewModel()) { - val protocolText = stringResource(R.string.redeem_protocol_text) - +fun RedeemScreen(taskIds: List, navController: NavController) { + val redeemVM: RedeemViewModel by rememberViewModel() val state by produceState(redeemVM.defaultState) { redeemVM.screenState(taskIds).collect { value = it @@ -161,11 +161,10 @@ fun RedeemScreen(taskIds: List, navController: NavController, redeemVM: ) } ) { - if (showRedeemScannedDialog) { RedeemScannedPrescriptionsDialog( onClickRedeem = { - redeemVM.redeemPrescriptions(taskIds, protocolText) + redeemVM.redeemPrescriptions(taskIds) navController.popBackStack() } ) { @@ -183,7 +182,7 @@ fun RedeemScreen(taskIds: List, navController: NavController, redeemVM: Column(verticalArrangement = Arrangement.SpaceBetween) { Text( stringResource(R.string.redeem_subtitle), - style = MaterialTheme.typography.subtitle2, + style = AppTheme.typography.subtitle2, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp) ) @@ -214,7 +213,7 @@ fun RedeemScreen(taskIds: List, navController: NavController, redeemVM: Column { Box { DataMatrixCode( - matrixCode = code.matrixCode, + payload = code.payload, modifier = mod.aspectRatio(1f) ) } @@ -230,7 +229,7 @@ fun RedeemScreen(taskIds: List, navController: NavController, redeemVM: R.string.redeem_txt_code_description, annotatedStringBold(code.nrOfCodes.toString()) ), - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2, textAlign = TextAlign.Center, modifier = mod ) @@ -293,7 +292,6 @@ private fun SwitchScreenMode( icon: ImageVector, onClick: () -> Unit ) { - Box( modifier = modifier.fillMaxWidth() ) { @@ -306,11 +304,10 @@ private fun SwitchScreenMode( .align(Alignment.Center), contentPadding = PaddingValues() ) { - Text( text, color = MaterialTheme.colors.secondary, - style = MaterialTheme.typography.subtitle2, + style = AppTheme.typography.subtitle2 ) Icon( icon, @@ -339,7 +336,6 @@ private fun Counter( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Box( modifier = Modifier.size(48.dp) ) { @@ -368,9 +364,9 @@ private fun Counter( annotatedStringResource( R.string.redeem_counter_text, annotatedStringBold((page + 1).toString()), - annotatedStringBold(maxPages.toString()), + annotatedStringBold(maxPages.toString()) ), - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1, modifier = Modifier .padding(horizontal = 12.dp, vertical = 4.dp) .align(Alignment.Center) @@ -397,19 +393,21 @@ private fun Counter( } @Composable -fun DataMatrixCode(matrixCode: BitMatrixCode, modifier: Modifier) { +fun DataMatrixCode(payload: String, modifier: Modifier) { + val matrix = remember(payload) { createBitMatrix(payload) } + Box( modifier = Modifier .then(modifier) .background(Color.White) - .padding(16.dp) + .padding(PaddingDefaults.Small) ) { Box( modifier = Modifier .fillMaxSize() .drawWithCache { val bmp = Bitmap.createScaledBitmap( - matrixCode.matrix.toBitmap(), + matrix.toBitmap(), max(size.width.roundToInt(), 10), max(size.height.roundToInt(), 10), false @@ -425,7 +423,6 @@ fun DataMatrixCode(matrixCode: BitMatrixCode, modifier: Modifier) { @Composable private fun RedeemScannedPrescriptionsDialog(onClickRedeem: () -> Unit, onCancel: () -> Unit) { - CommonAlertDialog( header = stringResource(R.string.redeem_prescriptions_dialog_header), info = stringResource(R.string.redeem_prescriptions_dialog_info), @@ -439,7 +436,6 @@ private fun RedeemScannedPrescriptionsDialog(onClickRedeem: () -> Unit, onCancel @Composable private fun RedeemSyncedPrescriptionsDialog(onClick: () -> Unit) { - AcceptDialog( header = stringResource(R.string.redeem_synced_prescriptions_dialog_header), info = stringResource(R.string.redeem_synced_prescriptions_dialog_info), diff --git a/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemViewModel.kt index 2693e8b5..faea69de 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/redeem/ui/RedeemViewModel.kt @@ -21,22 +21,19 @@ package de.gematik.ti.erp.app.redeem.ui import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.viewModelScope -import com.google.common.math.IntMath import com.google.zxing.BarcodeFormat import com.google.zxing.common.BitMatrix import com.google.zxing.datamatrix.DataMatrixWriter -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.db.entities.LowDetailEventSimple +import androidx.lifecycle.ViewModel import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import java.time.OffsetDateTime -import javax.inject.Inject +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject @@ -47,108 +44,100 @@ data class BitMatrixCode(val matrix: BitMatrix) object RedeemScreen { @Stable data class SingleCode( - val matrixCode: BitMatrixCode, + val payload: String, val nrOfCodes: Int, val isScanned: Boolean ) @Immutable data class State( - val maxTaskPerCode: Int, val showSingleCodes: Boolean, val codes: List ) } -@HiltViewModel -class RedeemViewModel @Inject constructor( +class RedeemViewModel( private val prescriptionUseCase: PrescriptionUseCase, - private val dispatchProvider: DispatchProvider -) : BaseViewModel() { - + private val profilesUseCase: ProfilesUseCase, + private val dispatchers: DispatchProvider +) : ViewModel() { private val showSingleCodes = MutableStateFlow(false) - private val maxTasksPerCode = MutableStateFlow(3) val defaultState = RedeemScreen.State( - maxTasksPerCode.value, - showSingleCodes.value, - listOf() + showSingleCodes = showSingleCodes.value, + codes = listOf() ) - fun screenState(taskIds: List): Flow { - var codes = listOf() - return showSingleCodes.map { showSingle -> - val maxTasks = if (!showSingle) { - if ((IntMath.mod(taskIds.size, 2) == 0)) { + @OptIn(ExperimentalCoroutinesApi::class) + fun screenState(taskIds: List): Flow = + showSingleCodes.flatMapLatest { showSingle -> + val maxTasks = if (showSingle) { + 1 + } else { + if (taskIds.size < 5) { 2 } else { 3 } - } else { - 1 } - if (maxTasks == 3 && taskIds.size > 5) { - generateRedeemCodes(taskIds.subList(0, 2), maxTasks).collect { - codes = it - } - generateRedeemCodes( - taskIds.subList(3, taskIds.size - 1), - maxTasks, - ).collect { - codes = codes + it - } - } else { - generateRedeemCodes(taskIds, maxTasks).collect { + + generateRedeemCodes(taskIds, maxTasks).map { + RedeemScreen.State( + showSingleCodes = showSingle, codes = it - } + ) } - RedeemScreen.State(maxTaskPerCode = maxTasks, showSingleCodes = showSingle, codes) } - } + @OptIn(ExperimentalCoroutinesApi::class) private fun generateRedeemCodes( taskIds: List, - maxTasksPerCode: Int, - ): Flow> { - - return prescriptionUseCase.tasks().take(1).map { - - val tasks = it - .asSequence() - .filter { task -> task.taskId in taskIds } - .distinctBy { task -> task.taskId } - - tasks - .toList() - .map { task -> - Pair( - task.scannedOn != null, - "Task/${task.taskId}/\$accept?ac=${task.accessCode}" - ) + maxTasksPerCode: Int + ): Flow> = + profilesUseCase.activeProfile.flatMapLatest { activeProfile -> + combine( + prescriptionUseCase.syncedTasks(activeProfile.id), + prescriptionUseCase.scannedTasks(activeProfile.id) + ) { syncedTasks, scannedTasks -> + val synced = syncedTasks.mapNotNull { + if (it.taskId in taskIds) { + Triple(it.taskId, it.accessCode!!, it.medicationRequestMedicationName()) + } else { + null + } } - .windowed(maxTasksPerCode, maxTasksPerCode, partialWindows = true) - .map { tasksList -> - val urls = tasksList.map { - it.second + val scanned = scannedTasks.mapNotNull { + if (it.taskId in taskIds) { + Triple(it.taskId, it.accessCode, null) + } else { + null } - val json = createPayload(urls).toString().replace("\\", "") - RedeemScreen.SingleCode( - BitMatrixCode(createBitMatrix(json)), - urls.size, - tasksList.first().first - ) } - .toList() + + (synced + scanned) + .map { (id, acc, name) -> + Pair( + name, + "Task/$id/\$accept?ac=$acc" + ) + } + .windowed(maxTasksPerCode, maxTasksPerCode, partialWindows = true) + .map { tasksList -> + val urls = tasksList.map { it.second } + val json = createPayload(urls).toString().replace("\\", "") + RedeemScreen.SingleCode( + payload = json, + nrOfCodes = urls.size, + isScanned = tasksList.first().first == null // TODO add name handling + ) + } + } } - } - fun redeemPrescriptions(taskIds: List, protocolText: String) { - viewModelScope.launch(dispatchProvider.io()) { - prescriptionUseCase.redeem(taskIds, true, true) - val now = OffsetDateTime.now() + fun redeemPrescriptions(taskIds: List) { + viewModelScope.launch(dispatchers.IO) { taskIds.forEach { taskId -> - val lowDetailEvent = LowDetailEventSimple(protocolText, now, taskId) - prescriptionUseCase.saveLowDetailEvent(lowDetailEvent) + prescriptionUseCase.redeemScannedTask(taskId, true) } } } @@ -166,8 +155,8 @@ class RedeemViewModel @Inject constructor( rootObject.put("urls", urls) return rootObject } - - private fun createBitMatrix(data: String): BitMatrix = - // width & height is unused in the underlying implementation - DataMatrixWriter().encode(data, BarcodeFormat.DATA_MATRIX, 1, 1) } + +fun createBitMatrix(data: String): BitMatrix = + // width & height is unused in the underlying implementation + DataMatrixWriter().encode(data, BarcodeFormat.DATA_MATRIX, 1, 1) diff --git a/android/src/debug/java/de/gematik/ti/erp/app/di/DevelopHeadersModule.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/SettingsModule.kt similarity index 52% rename from android/src/debug/java/de/gematik/ti/erp/app/di/DevelopHeadersModule.kt rename to android/src/main/java/de/gematik/ti/erp/app/settings/SettingsModule.kt index eef39448..3febb5f5 100644 --- a/android/src/debug/java/de/gematik/ti/erp/app/di/DevelopHeadersModule.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/SettingsModule.kt @@ -16,27 +16,18 @@ * */ -package de.gematik.ti.erp.app.di +package de.gematik.ti.erp.app.settings -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.BuildKonfig -import okhttp3.Interceptor -import okhttp3.Request +import de.gematik.ti.erp.app.di.ApplicationPreferencesTag +import de.gematik.ti.erp.app.settings.repository.CardWallRepository +import de.gematik.ti.erp.app.settings.repository.SettingsRepository +import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.instance -@InstallIn(SingletonComponent::class) -@Module -object DevelopHeadersModule { - - @DevelopReleaseHeaderInterceptor - @Provides - fun providesHeaderInterceptor(): Interceptor = Interceptor { chain -> - val request: Request = - chain.request().newBuilder() - .header("X-Api-Key", BuildKonfig.ERP_API_KEY) - .build() - chain.proceed(request) - } +val settingsModule = DI.Module("settingsModule") { + bindProvider { CardWallRepository(prefs = instance(ApplicationPreferencesTag)) } + bindProvider { SettingsRepository(instance(), instance()) } + bindProvider { SettingsUseCase(instance(), instance()) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/repository/CardWallRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/repository/CardWallRepository.kt index ad9268aa..45d29cd4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/repository/CardWallRepository.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/repository/CardWallRepository.kt @@ -21,17 +21,11 @@ package de.gematik.ti.erp.app.settings.repository import android.content.SharedPreferences import androidx.core.content.edit import de.gematik.ti.erp.app.BuildKonfig -import de.gematik.ti.erp.app.di.ApplicationPreferences -import javax.inject.Inject - -// TODO -// private const val AUTHENTICATION_METHOD = "authenticationMethod" private const val FAKE_NFC_CAPABILITIES = "fake_nfc_capabilities" -private const val CDW_INTRO_ACCEPTED = "cdwIntroAccepted" -class CardWallRepository @Inject constructor( - @ApplicationPreferences private val prefs: SharedPreferences +class CardWallRepository( + private val prefs: SharedPreferences ) { var hasFakeNFCEnabled: Boolean get() = @@ -41,8 +35,4 @@ class CardWallRepository @Inject constructor( false } set(value) = prefs.edit { putBoolean(FAKE_NFC_CAPABILITIES, value) } - - var introAccepted: Boolean - get() = prefs.getBoolean(CDW_INTRO_ACCEPTED, false) - set(value) = prefs.edit { putBoolean(CDW_INTRO_ACCEPTED, value) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt deleted file mode 100644 index 379a9142..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.settings.repository - -import androidx.room.withTransaction -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.PasswordEntity -import de.gematik.ti.erp.app.db.entities.Settings -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import de.gematik.ti.erp.app.prescription.repository.LocalDataSource -import de.gematik.ti.erp.app.secureRandomInstance -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onStart -import java.security.MessageDigest -import java.time.LocalDate -import javax.inject.Inject - -class SettingsRepository @Inject constructor( - private val db: AppDatabase, - private val localDataSource: LocalDataSource, -) { - fun settings(): Flow { - return db.settingsDao().getSettings().onStart { - db.withTransaction { - if (!db.settingsDao().isNotEmpty()) { - db.settingsDao().insertSettings( - Settings( - authenticationMethod = SettingsAuthenticationMethod.Unspecified, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - } - } - } - - suspend fun savePharmacySearch( - name: String, - locationEnabled: Boolean, - filterReady: Boolean, - filterDeliveryService: Boolean, - filterOnlineService: Boolean, - filterOpenNow: Boolean - ) { - db.settingsDao().updatePharmacySearch( - name = name, - locationEnabled = locationEnabled, - filterReady = filterReady, - filterDeliveryService = filterDeliveryService, - filterOnlineService = filterOnlineService, - filterOpenNow = filterOpenNow - ) - } - - suspend fun saveZoomPreference(enabled: Boolean) { - db.settingsDao().updateZoom(enabled) - } - - suspend fun saveAuthenticationMethod(authenticationMethod: SettingsAuthenticationMethod) { - db.settingsDao().updateAuthenticationMethod(authenticationMethod, null, null) - } - - suspend fun incrementNumberOfAuthenticationFailures() { - db.settingsDao().incrementNumberOfAuthenticationFailures() - } - - suspend fun resetNumberOfAuthenticationFailures() { - db.settingsDao().resetNumberOfAuthenticationFailures() - } - - suspend fun acceptInsecureDevice() { - db.settingsDao().acceptInsecureDevice() - } - - suspend fun savePasswordAsAuthenticationMethod(password: String) { - val salt = ByteArray(32).apply { - secureRandomInstance().nextBytes(this) - } - - val hash = hashPasswordWithSalt(password, salt) - - db.settingsDao().updateAuthenticationMethod(SettingsAuthenticationMethod.Password, hash = hash, salt = salt) - } - - fun hashPasswordWithSalt(password: String, salt: ByteArray): ByteArray { - val combined = password.toByteArray() + salt - - return MessageDigest.getInstance("SHA-256").digest(combined) - } - - suspend fun loadPassword(): PasswordEntity? = - db.settingsDao().getSettings().first().password - - suspend fun updatedDataTermsAccepted(date: LocalDate) { - db.settingsDao().acceptDataProtectionVersion(date) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt new file mode 100644 index 00000000..f3f29000 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AccessibilitySettingsScreen.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Camera +import androidx.compose.material.icons.rounded.ZoomIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.settings.ui.SettingsScreen +import de.gematik.ti.erp.app.settings.ui.SettingsViewModel +import de.gematik.ti.erp.app.utils.compose.AcceptDialog +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.LabeledSwitch +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium + +@Composable +fun AccessibilitySettingsScreen(settingsViewModel: SettingsViewModel, onBack: () -> Unit) { + val state by produceState(SettingsScreen.defaultState) { + settingsViewModel.screenState().collect { + value = it + } + } + + val listState = rememberLazyListState() + + var showAllowScreenShotsAlert by remember { mutableStateOf(false) } + + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.settings_accessibility_headline), + navigationMode = NavigationBarMode.Back, + listState = listState, + onBack = onBack + ) { + LazyColumn( + contentPadding = it, + state = listState + ) { + item { + SpacerMedium() + ZoomSection(zoomChecked = state.zoomEnabled) { zoomEnabled -> + when (zoomEnabled) { + true -> settingsViewModel.onEnableZoom() + false -> settingsViewModel.onDisableZoom() + } + } + } + item { + AllowScreenShotsSection( + state.screenshotsAllowed + ) { screenShotsAllowed -> + settingsViewModel.onSwitchAllowScreenshots(screenShotsAllowed) + showAllowScreenShotsAlert = true + } + } + } + if (showAllowScreenShotsAlert) { + RestartAlert { showAllowScreenShotsAlert = false } + } + } +} + +@Composable +private fun ZoomSection( + modifier: Modifier = Modifier, + zoomChecked: Boolean, + onZoomChange: (Boolean) -> Unit +) { + LabeledSwitch( + modifier = modifier, + checked = zoomChecked, + onCheckedChange = onZoomChange, + icon = Icons.Rounded.ZoomIn, + header = stringResource(R.string.settings_accessibility_zoom_toggle), + description = stringResource(R.string.settings_accessibility_zoom_info) + ) +} + +@Composable +private fun AllowScreenShotsSection( + allowScreenshots: Boolean, + modifier: Modifier = Modifier, + onAllowScreenshotsChange: (Boolean) -> Unit +) { + LabeledSwitch( + modifier = modifier, + checked = !allowScreenshots, + onCheckedChange = { + onAllowScreenshotsChange(!it) + }, + icon = Icons.Rounded.Camera, + header = stringResource(R.string.settings_screenshots_toggle_text), + description = stringResource(R.string.settings_screenshots_description) + ) +} + +@Composable +private fun RestartAlert(onDismissRequest: () -> Unit) { + val title = stringResource(R.string.settings_screenshots_alert_headline) + val message = stringResource(R.string.settings_screenshots_alert_info) + val confirmText = stringResource(R.string.settings_screenshots_button_text) + + AcceptDialog( + header = title, + onClickAccept = onDismissRequest, + info = message, + acceptText = confirmText + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowAnalyticsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowAnalyticsScreen.kt index c1528a89..9bf182a2 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowAnalyticsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowAnalyticsScreen.kt @@ -19,35 +19,34 @@ package de.gematik.ti.erp.app.settings.ui import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.onboarding.ui.OnboardingBottomBar import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer16 -import de.gematik.ti.erp.app.utils.compose.Spacer24 -import de.gematik.ti.erp.app.utils.compose.Spacer8 import de.gematik.ti.erp.app.utils.compose.annotatedStringBold import de.gematik.ti.erp.app.utils.compose.annotatedStringResource import de.gematik.ti.erp.app.utils.compose.createToastShort -import java.util.Locale @Composable -fun AllowAnalyticsScreen(onAllowAnalytics: (Boolean) -> Unit) { +fun AllowAnalyticsScreen(onAllowAnalytics: (Boolean) -> Unit, onBack: () -> Unit) { val context = LocalContext.current val allowStars = stringResource(R.string.settings_tracking_allow_emoji) val allowText = annotatedStringResource( @@ -55,72 +54,77 @@ fun AllowAnalyticsScreen(onAllowAnalytics: (Boolean) -> Unit) { annotatedStringBold(allowStars) ).toString() val disAllowToast = stringResource(R.string.settings_tracking_disallow_info) + val lazyListState = rememberLazyListState() - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = stringResource(R.string.settings_tracking_allow_title), - ) { onAllowAnalytics(false) } + AnimatedElevationScaffold( + modifier = Modifier.navigationBarsPadding(), + navigationMode = NavigationBarMode.Back, + topBarTitle = stringResource(R.string.settings_tracking_allow_title), + onBack = { + onAllowAnalytics(false) + createToastShort(context, disAllowToast) + onBack() }, + listState = lazyListState, bottomBar = { - BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { - Spacer24() - TextButton( - onClick = { - onAllowAnalytics(false) - createToastShort(context, disAllowToast) - } - ) { - Text(stringResource(R.string.settings_tracking_not_allow).uppercase(Locale.getDefault())) - } - Spacer(modifier = Modifier.weight(1f)) - TextButton( - onClick = { - onAllowAnalytics(true) - createToastShort(context, allowText) - } - ) { - Text(stringResource(R.string.settings_tracking_allow).uppercase(Locale.getDefault())) - } - Spacer24() - } + OnboardingBottomBar( + buttonText = stringResource(R.string.settings_tracking_allow_button), + onButtonClick = { + onAllowAnalytics(true) + createToastShort(context, allowText) + onBack() + }, + buttonEnabled = true, + info = null, + buttonModifier = Modifier.testTag(TestTag.Onboarding.Analytics.AcceptAnalyticsButton) + ) } ) { - Column( + LazyColumn( + state = lazyListState, modifier = Modifier + .wrapContentSize() .padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = PaddingDefaults.Medium, - bottom = (PaddingDefaults.XLarge * 2) + horizontal = PaddingDefaults.Medium ) - .verticalScroll(rememberScrollState()) + .padding(bottom = it.calculateBottomPadding()) + .testTag(TestTag.Onboarding.Analytics.ScreenContent) ) { - Text( - stringResource(R.string.settings_tracking_dialog_title), - style = MaterialTheme.typography.h6, - color = AppTheme.colors.neutral999, - ) - Spacer8() - Text( - stringResource(R.string.settings_tracking_dialog_text_1), - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral999, - ) - Spacer8() - Text( - stringResource(R.string.settings_tracking_dialog_text_2), - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral999 - ) - Spacer8() - Text( - stringResource(R.string.settings_tracking_dialog_text_3), - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral999, - ) - Spacer16() + item { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + Text( + stringResource(R.string.settings_tracking_dialog_title), + style = AppTheme.typography.h6, + modifier = Modifier.padding( + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Large + ) + ) + + Text( + stringResource(R.string.settings_tracking_dialog_text_1), + style = AppTheme.typography.body1, + modifier = Modifier.padding(bottom = PaddingDefaults.Small) + ) + + Text( + stringResource(R.string.settings_tracking_dialog_text_2), + style = AppTheme.typography.body1, + modifier = Modifier.padding(bottom = PaddingDefaults.Small) + ) + + Text( + stringResource(R.string.settings_tracking_dialog_text_3), + style = AppTheme.typography.body1, + modifier = Modifier.padding(bottom = PaddingDefaults.Medium) + ) + } + } } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowBiometryScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowBiometryScreen.kt new file mode 100644 index 00000000..401f4d4f --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowBiometryScreen.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.onboarding.ui.OnboardingSecureAppMethod +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.userauthentication.ui.BiometricPrompt +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.semantics +import androidx.compose.foundation.layout.navigationBarsPadding +import de.gematik.ti.erp.app.onboarding.ui.OnboardingBottomBar +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold + +@Composable +fun AllowBiometryScreen( + onBack: () -> Unit, + onNext: () -> Unit, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit +) { + var showBiometricPrompt by remember { mutableStateOf(false) } + val lazyListState = rememberLazyListState() + + AnimatedElevationScaffold( + modifier = Modifier.navigationBarsPadding(), + navigationMode = NavigationBarMode.Close, + bottomBar = { + OnboardingBottomBar( + buttonText = stringResource(R.string.settings_device_security_allow), + onButtonClick = { + showBiometricPrompt = true + }, + buttonEnabled = true, + info = null, + buttonModifier = Modifier + ) + }, + topBarTitle = stringResource(R.string.settings_biometric_dialog_headline), + listState = lazyListState, + onBack = onBack + ) { + if (showBiometricPrompt) { + BiometricPrompt( + authenticationMethod = SettingsData.AuthenticationMode.DeviceSecurity, + title = stringResource(R.string.auth_prompt_headline), + description = "", + negativeButton = stringResource(R.string.auth_prompt_cancel), + onAuthenticated = { + onSecureMethodChange(OnboardingSecureAppMethod.DeviceSecurity) + onNext() + }, + onCancel = { + onBack() + }, + onAuthenticationError = { + onBack() + }, + onAuthenticationSoftError = { + } + ) + } + + LazyColumn( + state = lazyListState, + modifier = Modifier + .wrapContentSize() + .padding( + horizontal = PaddingDefaults.Medium + ) + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + Text( + stringResource(R.string.settings_biometric_dialog_title), + style = AppTheme.typography.h6, + modifier = Modifier.padding( + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Large + ) + ) + + Text( + text = stringResource(R.string.settings_biometric_dialog_text), + style = AppTheme.typography.body1, + modifier = Modifier.padding( + bottom = PaddingDefaults.Small + ) + ) + } + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt index bf4abed5..b7087d40 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AuditEventsScreen.kt @@ -19,31 +19,35 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier - +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemsIndexed -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues import de.gematik.ti.erp.app.R - +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.settings.ui.SettingsViewModel import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar +import de.gematik.ti.erp.app.utils.compose.SpacerSmall import de.gematik.ti.erp.app.utils.compose.phrasedDateString import java.time.Instant import java.time.LocalDateTime @@ -52,24 +56,23 @@ import java.time.ZoneId @OptIn(ExperimentalFoundationApi::class) @Composable fun AuditEventsScreen( - profileName: String, + profileId: ProfileIdentifier, viewModel: SettingsViewModel, lastAuthenticated: Instant?, tokenValid: Boolean, onBack: () -> Unit ) { - val header = stringResource(id = R.string.autitEvents_headline) - val auditEventPagingFlow = remember { viewModel.loadAuditEventsForProfile(profileName) } + val header = stringResource(R.string.autitEvents_headline) + val auditEventPagingFlow = remember(profileId) { viewModel.loadAuditEventsForProfile(profileId) } val pagingItems = auditEventPagingFlow.collectAsLazyPagingItems() + val listState = rememberLazyListState() - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = header, - onBack = onBack - ) - }, + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Profile.AuditEvents.AuditEventsScreen), + listState = listState, + topBarTitle = header, + onBack = onBack, + navigationMode = NavigationBarMode.Back ) { innerPadding -> val infoText = if (lastAuthenticated == null) { @@ -78,34 +81,36 @@ fun AuditEventsScreen( stringResource(R.string.no_audit_events_empty_protocol_list_info) } - if (lastAuthenticated == null || pagingItems.itemCount == 0) { - Column( + if (pagingItems.itemCount == 0) { + LazyColumn( modifier = Modifier .padding(PaddingDefaults.Medium) .fillMaxSize(), + state = listState, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - stringResource(R.string.no_audit_events_header), - style = MaterialTheme.typography.subtitle1 - ) - Text( - infoText, - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center - ) + item { + Text( + stringResource(R.string.no_audit_events_header), + modifier = Modifier.testTag(TestTag.Profile.AuditEvents.NoAuditEventHeader), + style = AppTheme.typography.subtitle1 + ) + SpacerSmall() + Text( + infoText, + style = AppTheme.typography.body2l, + modifier = Modifier.testTag(TestTag.Profile.AuditEvents.NoAuditEventInfo), + textAlign = TextAlign.Center + ) + } } } else { - LazyColumn( modifier = Modifier.padding(innerPadding), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) + state = listState, + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() ) { - if (!tokenValid) { item { Column( @@ -128,7 +133,7 @@ fun AuditEventsScreen( id = R.string.audit_events_updated_at, phrasedDateString(date = lastAuthenticatedDate) ), - style = AppTheme.typography.captionl, + style = AppTheme.typography.caption1l, textAlign = TextAlign.Center ) } @@ -137,17 +142,21 @@ fun AuditEventsScreen( itemsIndexed(pagingItems) { _, auditEvent -> auditEvent?.let { - Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { - if (auditEvent.medicationText != null) { + Column( + modifier = Modifier.padding(PaddingDefaults.Medium) + .testTag(TestTag.Profile.AuditEvents.AuditEvent) + ) { + auditEvent.medicationText?.let { Text( - auditEvent.medicationText, - style = MaterialTheme.typography.subtitle1 + it, + style = AppTheme.typography.subtitle1 ) } - Text(auditEvent.text, style = MaterialTheme.typography.body2) + + Text(auditEvent.description, style = AppTheme.typography.body2) val timestamp = remember { - auditEvent.timeStamp.atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + LocalDateTime.ofInstant(auditEvent.timestamp, ZoneId.systemDefault()) } Text( diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt new file mode 100644 index 00000000..15b40925 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/DeviceSecuritySettingsScreen.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.RadioButtonUnchecked +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.userauthentication.ui.BiometricPrompt +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium + +@Composable +fun DeviceSecuritySettingsScreen( + settingsViewModel: SettingsViewModel, + onBack: () -> Unit, + onClickProtectionMode: (SettingsData.AuthenticationMode) -> Unit + +) { + val authenticationMode by produceState(SettingsScreen.defaultState.authenticationMode) { + settingsViewModel.screenState().collect { + value = it.authenticationMode + } + } + + val listState = rememberLazyListState() + + var showBiometricPrompt by rememberSaveable { mutableStateOf(false) } + + if (showBiometricPrompt) { + BiometricPrompt( + authenticationMethod = SettingsData.AuthenticationMode.DeviceSecurity, + title = stringResource(R.string.auth_prompt_headline), + description = "", + negativeButton = stringResource(R.string.auth_prompt_cancel), + onAuthenticated = { + onClickProtectionMode(SettingsData.AuthenticationMode.DeviceSecurity) + showBiometricPrompt = false + }, + onCancel = { + showBiometricPrompt = false + }, + onAuthenticationError = { + showBiometricPrompt = false + }, + onAuthenticationSoftError = { + } + ) + } + + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.settings_device_security_header), + navigationMode = NavigationBarMode.Back, + listState = listState, + onBack = onBack + ) { + LazyColumn( + contentPadding = it, + state = listState + ) { + item { + SpacerMedium() + AuthenticationModeCard( + Icons.Outlined.Fingerprint, + checked = authenticationMode == SettingsData.AuthenticationMode.DeviceSecurity, + headline = stringResource(R.string.settings_appprotection_device_security_header), + info = stringResource(R.string.settings_appprotection_device_security_info), + deviceSecurity = true + ) { + showBiometricPrompt = true + } + } + item { + AuthenticationModeCard( + Icons.Outlined.Security, + checked = authenticationMode is SettingsData.AuthenticationMode.Password, + headline = stringResource(R.string.settings_appprotection_mode_password_headline), + info = stringResource(R.string.settings_appprotection_mode_password_info) + ) { + onClickProtectionMode(SettingsData.AuthenticationMode.Password("")) + } + } + } + } +} + +@Composable +private fun AuthenticationModeCard( + icon: ImageVector, + checked: Boolean, + headline: String, + info: String, + deviceSecurity: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit +) { + var showAllowDeviceSecurity by remember { mutableStateOf(false) } + + if (deviceSecurity && showAllowDeviceSecurity && !checked) { + CommonAlertDialog( + header = stringResource(R.string.settings_biometric_dialog_title), + info = stringResource(R.string.settings_biometric_dialog_text), + actionText = stringResource(R.string.settings_device_security_allow), + cancelText = stringResource(R.string.cancel), + onCancel = { showAllowDeviceSecurity = false }, + onClickAction = { + onClick() + showAllowDeviceSecurity = false + } + ) + } + + val alpha = remember { Animatable(0.0f) } + + LaunchedEffect(checked) { + if (checked) { + alpha.animateTo(1.0f) + } else { + alpha.animateTo(0.0f) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clickable( + onClick = { + if (deviceSecurity) { + showAllowDeviceSecurity = true + } else { + onClick() + } + }, + enabled = enabled + ) + .padding(PaddingDefaults.Medium) + ) { + Icon( + icon, + null, + tint = AppTheme.colors.primary500, + modifier = Modifier.padding(end = PaddingDefaults.Small) + ) + Column(modifier = Modifier.weight(1.0f)) { + Text( + text = headline, + style = AppTheme.typography.body1 + ) + Text( + text = info, + style = AppTheme.typography.body2l + ) + } + + Box(modifier = Modifier.align(Alignment.CenterVertically)) { + Icon( + Icons.Rounded.RadioButtonUnchecked, + null, + tint = AppTheme.colors.neutral400 + ) + Icon( + Icons.Rounded.CheckCircle, + null, + tint = AppTheme.colors.primary600, + modifier = Modifier.alpha(alpha.value) + ) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/FeedbackFormScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/FeedbackFormScreen.kt deleted file mode 100644 index bac3e486..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/FeedbackFormScreen.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.settings.ui - -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar -import androidx.compose.material.Button -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import de.gematik.ti.erp.app.BuildConfig -import de.gematik.ti.erp.app.BuildKonfig -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.SpacerMedium -import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.annotatedStringResource -import de.gematik.ti.erp.app.utils.compose.handleIntent -import de.gematik.ti.erp.app.utils.compose.provideEmailIntent -import java.util.Locale - -@Composable -fun FeedbackForm(navController: NavController) { - var sendEnabled by remember { mutableStateOf(false) } - var body by rememberSaveable { mutableStateOf("") } - val subject = "Feedback aus der E-Rezept App" - val mailAddress = stringResource(R.string.settings_contact_mail_address) - - val context = LocalContext.current - val darkMode = isSystemInDarkTheme() - - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = stringResource(R.string.settings_feedback_form_headline), - ) { navController.popBackStack() } - }, - bottomBar = { - BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - context.handleIntent( - provideEmailIntent( - mailAddress, - body = buildBodyWithDeviceInfo(body, darkMode), - subject = subject - ) - ) - }, - enabled = sendEnabled, - shape = RoundedCornerShape(PaddingDefaults.Small) - ) { - Text(stringResource(R.string.settings_feedback_form_send).uppercase(Locale.getDefault())) - } - SpacerMedium() - } - } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - .padding(PaddingDefaults.Medium) - ) { - Text(stringResource(R.string.settings_feedback_form_header), style = MaterialTheme.typography.h6) - - SpacerMedium() - - OutlinedTextField( - value = body, - onValueChange = { - body = it - sendEnabled = body.isNotBlank() - }, - textStyle = MaterialTheme.typography.body2, - placeholder = { - Text( - stringResource(R.string.settings_feedback_form_placeholder), - style = MaterialTheme.typography.body2 - ) - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 200.dp, max = 400.dp) - ) - - CompositionLocalProvider( - LocalTextStyle provides AppTheme.typography.body2l, - ) { - SpacerSmall() - Column(verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small)) { - Text(stringResource(R.string.seetings_feedback_form_additional_data_info)) - val os = detail( - stringResource(R.string.seetings_feedback_form_additional_data_os), - annotatedStringResource( - R.string.seetings_feedback_form_additional_data_os_detail, - Build.VERSION.RELEASE, - Build.VERSION.SDK_INT, - Build.VERSION.SECURITY_PATCH - ) - ) - - val device = detail( - stringResource(R.string.seetings_feedback_form_additional_data_device), - annotatedStringResource( - R.string.seetings_feedback_form_additional_data_device_detail, - Build.MANUFACTURER, - Build.MODEL, - Build.PRODUCT - ) - ) - Text(os) - Text(device) - val darkModeText = detail( - stringResource(R.string.seetings_feedback_form_additional_data_darkmode), - AnnotatedString( - stringResource( - if (darkMode) { - R.string.seetings_feedback_form_additional_data_darkmode_on - } else { - R.string.seetings_feedback_form_additional_data_darkmode_off - } - ) - ) - ) - Text(darkModeText) - Text( - detail( - stringResource(R.string.seetings_feedback_form_additional_data_language), - AnnotatedString(Locale.getDefault().displayName) - ) - ) - } - } - } - } -} - -@Composable -private fun detail( - header: String, - detail: AnnotatedString -): AnnotatedString = - buildAnnotatedString { - withStyle(AppTheme.typography.subtitle2l.toSpanStyle()) { - append(header) - append(": ") - } - withStyle(AppTheme.typography.body2l.toSpanStyle()) { - append(detail) - } - } - -private fun buildBodyWithDeviceInfo(userBody: String, darkMode: Boolean): String = - """$userBody - | - | - |Systeminformationen - | - |Betriebssystem: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}) (PATCH ${Build.VERSION.SECURITY_PATCH}) - |Modell: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.PRODUCT}) - |App Version: ${BuildConfig.VERSION_NAME} (${BuildKonfig.GIT_HASH}) - |DarkMode: ${if (darkMode) "an" else "aus"} - |Sprache: ${Locale.getDefault().displayName} - | - """.trimMargin() diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/OrderHealthCardHint.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/OrderHealthCardHint.kt deleted file mode 100644 index f018b7e4..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/OrderHealthCardHint.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.settings.ui - -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.theme.AppTheme -import de.gematik.ti.erp.app.utils.compose.HintCard -import de.gematik.ti.erp.app.utils.compose.HintCardDefaults -import de.gematik.ti.erp.app.utils.compose.HintSmallImage -import de.gematik.ti.erp.app.utils.compose.HintTextActionButton - -@Composable -fun OrderHealthCardHint( - modifier: Modifier = Modifier, - onClick: () -> Unit -) = - AppTheme { - HintCard( - modifier = modifier, - image = { - HintSmallImage(painterResource(R.drawable.boy_green_shirt_card_circle), null, it) - }, - properties = HintCardDefaults.flatProperties(backgroundColor = Color.Unspecified), - title = { Text(stringResource(R.string.settings_health_insurance_contact_title)) }, - body = { Text(stringResource(R.string.settings_health_insurance_contact_body)) }, - action = { HintTextActionButton(stringResource(R.string.settings_health_insurance_contact_action), onClick = onClick) } - ) - } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PasswordScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PasswordScreen.kt index 9d82258d..d2a293ef 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PasswordScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PasswordScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -34,14 +35,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar import androidx.compose.material.Button import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon -import androidx.compose.material.IconToggleButton +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextFieldColors import androidx.compose.material.TextFieldDefaults @@ -49,12 +48,15 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.AutofillNode @@ -67,9 +69,9 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -79,14 +81,16 @@ import com.nulabinc.zxcvbn.Zxcvbn import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.BottomAppBar import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerTiny import de.gematik.ti.erp.app.utils.compose.annotatedStringResource +import kotlinx.coroutines.launch import java.util.Locale -private const val minimalPasswordScore = 2 +private const val MinimalPasswordScore = 2 @Composable fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewModel) { @@ -94,21 +98,24 @@ fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewM var repeatedPassword by remember { mutableStateOf("") } var passwordScore by remember { mutableStateOf(0) } val focusRequester = FocusRequester.Default + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = stringResource(R.string.settings_password_headline), - ) { navController.popBackStack() } - }, + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.settings_password_headline), + navigationMode = NavigationBarMode.Back, + onBack = { navController.popBackStack() }, + elevated = scrollState.value > 0, + actions = {}, bottomBar = { BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { Spacer(modifier = Modifier.weight(1f)) Button( onClick = { - viewModel.onSelectPasswordAsAuthenticationMode(password) - navController.popBackStack() + coroutineScope.launch { + viewModel.onSelectPasswordAsAuthenticationMode(password) + navController.popBackStack() + } }, enabled = checkPassword( password = password, @@ -128,7 +135,7 @@ fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewM modifier = Modifier .fillMaxSize() .padding(innerPadding) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(PaddingDefaults.Medium) ) { PasswordTextField( @@ -141,7 +148,7 @@ fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewM allowAutofill = true, allowVisiblePassword = true, label = { - Text(stringResource(R.string.settings_password_enter_password)) + Text(stringResource(R.string.settings_password_enter)) }, onSubmit = { focusRequester.requestFocus() } ) @@ -172,8 +179,10 @@ fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewM score = passwordScore ) ) { - viewModel.onSelectPasswordAsAuthenticationMode(password) - navController.popBackStack() + coroutineScope.launch { + viewModel.onSelectPasswordAsAuthenticationMode(password) + navController.popBackStack() + } } } ) @@ -218,44 +227,49 @@ fun PasswordTextField( } else { Modifier } + val passwordIsNotVisible = stringResource(R.string.password_is_not_visible) + val passwordIsVisible = stringResource(R.string.password_is_visible) OutlinedTextField( value = value, onValueChange = onValueChange, modifier = modifier .heightIn(min = 56.dp) - .then(autofillModifier), + .then(autofillModifier) + .semantics { + contentDescription = if (passwordVisible) { + passwordIsVisible + } else { + passwordIsNotVisible + } + }, singleLine = true, keyboardOptions = KeyboardOptions(autoCorrect = true, keyboardType = KeyboardType.Password), keyboardActions = KeyboardActions { - if (!isError && value.isNotEmpty()) { - onSubmit() - } + onSubmit() }, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { - if (allowVisiblePassword) { - IconToggleButton( - checked = passwordVisible, - onCheckedChange = { passwordVisible = it } + if (isConsistent) { + Icon( + Icons.Rounded.Check, + stringResource(R.string.consistent_password) + ) + } else if (allowVisiblePassword) { + IconButton( + onClick = { passwordVisible = !passwordVisible } ) { when (passwordVisible) { true -> Icon( - Icons.Outlined.VisibilityOff, + Icons.Outlined.Visibility, stringResource(R.string.settings_password_acc_show_password_toggle) ) false -> Icon( - Icons.Outlined.Visibility, + Icons.Outlined.VisibilityOff, stringResource(R.string.settings_password_acc_show_password_toggle) ) } } - } else if (isConsistent) { - Icon( - Icons.Rounded.Check, - stringResource(R.string.consistent_password), - tint = AppTheme.colors.green600 - ) } }, isError = isError, @@ -265,7 +279,6 @@ fun PasswordTextField( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun ConfirmationPasswordTextField( modifier: Modifier, @@ -275,11 +288,15 @@ fun ConfirmationPasswordTextField( onValueChange: (String) -> Unit, onSubmit: () -> Unit ) { - val isError = password.isNotBlank() && - value.isNotBlank() && - !password.startsWith(value) + val isError = remember(password, value) { + password.isNotBlank() && + value.isNotBlank() && + !password.startsWith(value) + } - val isConsistent = password.isNotBlank() && password == value && checkPasswordScore(passwordScore) + val isConsistent = remember(password, value) { + password.isNotBlank() && password == value && checkPasswordScore(passwordScore) + } PasswordTextField( modifier = modifier, @@ -287,9 +304,13 @@ fun ConfirmationPasswordTextField( onValueChange = onValueChange, isConsistent = isConsistent, isError = isError, - onSubmit = onSubmit, - allowAutofill = false, - allowVisiblePassword = false, + onSubmit = { + if (!isError && isConsistent) { + onSubmit() + } + }, + allowAutofill = true, + allowVisiblePassword = true, label = { Text(stringResource(R.string.settings_password_repeat_password)) }, @@ -304,6 +325,9 @@ fun ConfirmationPasswordTextField( unfocusedBorderColor = AppTheme.colors.green600.copy(alpha = ContentAlpha.high), unfocusedLabelColor = AppTheme.colors.green600.copy( alpha = ContentAlpha.high + ), + trailingIconColor = AppTheme.colors.green600.copy( + alpha = ContentAlpha.high ) ) } else { @@ -312,6 +336,8 @@ fun ConfirmationPasswordTextField( ) } +// tag::PasswordStrength[] + @Composable fun PasswordStrength( modifier: Modifier, @@ -339,32 +365,32 @@ fun PasswordStrength( } ) - LaunchedEffect(strength) { + DisposableEffect(strength) { onScoreChange(strength.score) + onDispose { } } Column( modifier = modifier .semantics(true) { - progressBarRangeInfo = ProgressBarRangeInfo( - current = strength.score.toFloat(), - range = 0f..4f, - steps = 1 - ) + stateDescription = if (checkPasswordScore(strength.score)) "sufficient" else "insufficient" } ) { val suggestions = strength.feedback.suggestions.joinToString("\n").trim() - Text( - annotatedStringResource( - R.string.settings_password_suggestions, - if (suggestions.isBlank()) { - stringResource(R.string.settings_password_hint) - } else { + if (password.isBlank() || suggestions.isBlank()) { + Text( + text = stringResource(R.string.settings_password_length_hint), + style = AppTheme.typography.caption1l + ) + } else { + Text( + text = annotatedStringResource( + R.string.settings_password_suggestions, suggestions - } - ), - style = AppTheme.typography.captionl - ) + ), + style = AppTheme.typography.caption1l + ) + } SpacerMedium() Box( @@ -380,15 +406,42 @@ fun PasswordStrength( ) } SpacerTiny() - Text( - stringResource(R.string.settings_password_strength), - style = AppTheme.typography.captionl - ) + + Row(verticalAlignment = Alignment.CenterVertically) { + when { + strength.score == 4 -> { + Text( + stringResource(R.string.settings_password_strength_very_good), + style = AppTheme.typography.body2l + ) + SpacerTiny() + Icon(Icons.Rounded.Check, null, tint = AppTheme.colors.green600) + } + strength.score > MinimalPasswordScore -> { + Text( + stringResource(R.string.settings_password_strength_sufficient), + style = AppTheme.typography.body2l + ) + SpacerTiny() + Icon(Icons.Rounded.Check, null, tint = AppTheme.colors.green600) + } + else -> { + Text( + stringResource(R.string.settings_password_strength_not_sufficient), + style = AppTheme.typography.body2l + ) + SpacerTiny() + Icon(Icons.Rounded.Close, null, tint = AppTheme.colors.red600) + } + } + } } } -private fun checkPasswordScore(score: Int): Boolean = - score > minimalPasswordScore +// end::PasswordStrength[] + +fun checkPasswordScore(score: Int): Boolean = + score > MinimalPasswordScore fun checkPassword(password: String, repeatedPassword: String, score: Int): Boolean = password.isNotBlank() && password == repeatedPassword && checkPasswordScore(score) diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PharmacyLicenseScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PharmacyLicenseScreen.kt new file mode 100644 index 00000000..f8557849 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/PharmacyLicenseScreen.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.provideLinkForString +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.ClickableTaggedText +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.annotatedStringResource + +@Composable +fun PharmacyLicenseScreen(onClose: () -> Unit) { + val scrollState = rememberScrollState() + + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.settings_licence_pharmacy_search), + navigationMode = NavigationBarMode.Close, + onBack = onClose, + elevated = scrollState.value > 0, + actions = {} + ) { + Column( + modifier = Modifier + .padding( + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium, + top = PaddingDefaults.Medium, + bottom = (PaddingDefaults.XLarge * 2) + ) + .verticalScroll(scrollState) + ) { + Text( + stringResource(R.string.license_pharmacy_search_description), + style = AppTheme.typography.body1, + color = AppTheme.colors.neutral999 + ) + + SpacerMedium() + + val link = + provideLinkForString( + stringResource(id = R.string.license_pharmacy_search_web_link), + annotation = stringResource(id = R.string.license_pharmacy_search_web_link), + tag = "URL", + linkColor = AppTheme.colors.primary500 + ) + + val uriHandler = LocalUriHandler.current + + ClickableTaggedText( + annotatedStringResource(R.string.license_pharmacy_search_web_link_info, link), + style = AppTheme.typography.body1, + onClick = { range -> + uriHandler.openUri(range.item) + } + ) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/ProductImprovementSettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/ProductImprovementSettingsScreen.kt new file mode 100644 index 00000000..b9f97830 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/ProductImprovementSettingsScreen.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material.icons.rounded.Timeline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.LabeledSwitch +import de.gematik.ti.erp.app.utils.compose.NavigationBarMode +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.handleIntent +import de.gematik.ti.erp.app.utils.compose.provideWebIntent + +@Composable +fun ProductImprovementSettingsScreen( + settingsViewModel: SettingsViewModel, + onAllowAnalytics: (Boolean) -> Unit, + onBack: () -> Unit +) { + val state by produceState(SettingsScreen.defaultState) { + settingsViewModel.screenState().collect { + value = it + } + } + + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.settings_product_improvement_headline), + navigationMode = NavigationBarMode.Back, + listState = listState, + onBack = onBack + ) { + LazyColumn( + contentPadding = it, + state = listState + ) { + item { + SpacerMedium() + AnalyticsSection( + state.analyticsAllowed + ) { allow -> + onAllowAnalytics(allow) + } + } + item { + SurveySection() + } + } + } +} + +@Composable +private fun SurveySection() { + val context = LocalContext.current + val surveyAddress = stringResource(R.string.settings_contact_survey_address) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = { context.handleIntent(provideWebIntent(surveyAddress)) }, + role = Role.Button + ) + .padding(PaddingDefaults.Medium) + .semantics(mergeDescendants = true) {} + ) { + Icon(Icons.Outlined.OpenInBrowser, null, tint = AppTheme.colors.primary600) + SpacerSmall() + Column( + modifier = Modifier + .weight(1.0f) + .padding(horizontal = PaddingDefaults.Small) + ) { + Text( + text = stringResource(R.string.settings_contact_feedback), + style = AppTheme.typography.body1 + ) + Text( + text = stringResource(R.string.settings_contact_feedback_description), + style = AppTheme.typography.body2l + ) + } + } +} + +@Composable +private fun AnalyticsSection( + analyticsAllowed: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit +) { + LabeledSwitch( + checked = analyticsAllowed, + onCheckedChange = onCheckedChange, + modifier = modifier, + icon = Icons.Rounded.Timeline, + header = stringResource(R.string.settings_allow_analytics_header), + description = stringResource(R.string.settings_allow_analytics_info) + ) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt index ed1a378d..2f78bd1b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsNavigation.kt @@ -18,72 +18,39 @@ package de.gematik.ti.erp.app.settings.ui -import TokenScreen +import AccessibilitySettingsScreen import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import androidx.navigation.NavHostController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import de.gematik.ti.erp.app.LegalNoticeWithScaffold import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.Route -import de.gematik.ti.erp.app.debug.ui.DebugScreenWrapper -import de.gematik.ti.erp.app.orderhealthcard.ui.HealthCardContactOrderScreen -import de.gematik.ti.erp.app.profiles.ui.EditProfileScreen -import de.gematik.ti.erp.app.profiles.ui.ProfileDestinations +import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens +import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.NavigationMode -import de.gematik.ti.erp.app.webview.URI_DATA_TERMS -import de.gematik.ti.erp.app.webview.URI_LICENCES -import de.gematik.ti.erp.app.webview.URI_TERMS_OF_USE -import de.gematik.ti.erp.app.webview.WebViewScreen -import kotlinx.coroutines.flow.collect +import de.gematik.ti.erp.app.utils.compose.createToastShort object SettingsNavigationScreens { object Settings : Route("Settings") - object Terms : Route("Terms") - object Imprint : Route("Imprint") - object DataProtection : Route("DataProtection") - object OpenSourceLicences : Route("OpenSourceLicences") - object AllowAnalytics : Route("AcceptAnalytics") - object FeedbackForm : Route("FeedbackForm") - object Password : Route("Password") - object Debug : Route("Debug") - object Token : Route("Token") - object OrderHealthCard : Route("OrderHealthCard") - object EditProfile : - Route("EditProfile", navArgument("profileId") { type = NavType.IntType }) { - fun path(profileId: Int) = path("profileId" to profileId) - } -} -enum class SettingsScrollTo { - None, - Authentication, - DemoMode, - Profiles + object AccessibilitySettings : Route("AccessibilitySettings") + object ProductImprovementSettings : Route("ProductImprovementSettings") + object DeviceSecuritySettings : Route("DeviceSecuritySettings") } +@Suppress("LongMethod") @Composable fun SettingsNavGraph( settingsNavController: NavHostController, navigationMode: NavigationMode, - scrollTo: SettingsScrollTo, mainNavController: NavController, settingsViewModel: SettingsViewModel ) { - val state by produceState(SettingsScreen.defaultState) { - settingsViewModel.screenState().collect { - value = it - } - } - NavHost( settingsNavController, startDestination = SettingsNavigationScreens.Settings.path() @@ -91,111 +58,46 @@ fun SettingsNavGraph( composable(SettingsNavigationScreens.Settings.route) { NavigationAnimation(mode = navigationMode) { SettingsScreenWithScaffold( - scrollTo, mainNavController = mainNavController, navController = settingsNavController, settingsViewModel = settingsViewModel ) } } - composable(SettingsNavigationScreens.Debug.route) { - NavigationAnimation(mode = navigationMode) { - DebugScreenWrapper(settingsNavController) - } - } - composable(SettingsNavigationScreens.Terms.route) { - NavigationAnimation(mode = navigationMode) { - WebViewScreen( - title = stringResource(R.string.onb_terms_of_use), - onBack = { settingsNavController.popBackStack() }, - url = URI_TERMS_OF_USE - ) - } - } - composable(SettingsNavigationScreens.Imprint.route) { - NavigationAnimation(mode = navigationMode) { - LegalNoticeWithScaffold( - settingsNavController - ) - } + composable(SettingsNavigationScreens.AccessibilitySettings.route) { + AccessibilitySettingsScreen( + settingsViewModel = settingsViewModel, + onBack = { settingsNavController.popBackStack() } + ) } - composable(SettingsNavigationScreens.DataProtection.route) { - NavigationAnimation(mode = navigationMode) { - WebViewScreen( - title = stringResource(R.string.onb_data_consent), - onBack = { settingsNavController.popBackStack() }, - url = URI_DATA_TERMS - ) - } - } - composable(SettingsNavigationScreens.OpenSourceLicences.route) { - NavigationAnimation(mode = navigationMode) { - WebViewScreen( - title = stringResource(R.string.settings_legal_licences), - onBack = { settingsNavController.popBackStack() }, - url = URI_LICENCES - ) - } - } - composable(SettingsNavigationScreens.AllowAnalytics.route) { - NavigationAnimation(mode = navigationMode) { - AllowAnalyticsScreen { - if (it) { - settingsViewModel.onTrackingAllowed() - } else { + composable(SettingsNavigationScreens.ProductImprovementSettings.route) { + val context = LocalContext.current + val disAllowAnalyticsToast = stringResource(R.string.settings_tracking_disallow_info) + + ProductImprovementSettingsScreen( + settingsViewModel = settingsViewModel, + onAllowAnalytics = { + if (!it) { settingsViewModel.onTrackingDisallowed() + createToastShort(context, disAllowAnalyticsToast) + } else { + mainNavController.navigate(MainNavigationScreens.AllowAnalytics.path()) } - settingsNavController.popBackStack() - } - } - } - composable(SettingsNavigationScreens.FeedbackForm.route) { - NavigationAnimation(mode = navigationMode) { - FeedbackForm( - settingsNavController - ) - } + }, + onBack = { settingsNavController.popBackStack() } + ) } - composable(SettingsNavigationScreens.Password.route) { - NavigationAnimation(mode = navigationMode) { - SecureAppWithPassword( - settingsNavController, - settingsViewModel - ) - } - } - composable(SettingsNavigationScreens.OrderHealthCard.route) { - HealthCardContactOrderScreen(onBack = { settingsNavController.popBackStack() }) - } - composable(ProfileDestinations.Token.route) { - val activeProfile = state.activeProfile() - NavigationAnimation(mode = NavigationMode.Closed) { - TokenScreen( - onBack = { settingsNavController.popBackStack() }, - ssoToken = activeProfile.ssoToken?.tokenOrNull(), - accessToken = activeProfile.accessToken, - ) - } - } - composable( - SettingsNavigationScreens.EditProfile.route, - SettingsNavigationScreens.EditProfile.arguments, - ) { - val profileId = - remember { settingsNavController.currentBackStackEntry!!.arguments!!.getInt("profileId") } - state.profileById(profileId)?.let { profile -> - EditProfileScreen( - state, - profile, - settingsViewModel, - onRemoveProfile = { - settingsViewModel.removeProfile(profile, it) - settingsNavController.popBackStack() - }, - onBack = { settingsNavController.popBackStack() }, - mainNavController = mainNavController - ) + composable(SettingsNavigationScreens.DeviceSecuritySettings.route) { + DeviceSecuritySettingsScreen( + settingsViewModel = settingsViewModel, + onBack = { settingsNavController.popBackStack() } + ) { + when (it) { + is SettingsData.AuthenticationMode.Password -> + mainNavController.navigate(MainNavigationScreens.Password.path()) + else -> settingsViewModel.onSelectDeviceSecurityAuthenticationMode() + } } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt index bede1d89..dcbf1ba9 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsScreen.kt @@ -18,157 +18,116 @@ package de.gematik.ti.erp.app.settings.ui -import androidx.biometric.BiometricManager -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.repeatable -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.background +import android.content.Context +import android.os.Build +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material.icons.outlined.AccessibilityNew +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.Mail -import androidx.compose.material.icons.outlined.OpenInBrowser import androidx.compose.material.icons.outlined.PrivacyTip import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Source +import androidx.compose.material.icons.outlined.Timeline import androidx.compose.material.icons.outlined.Wysiwyg -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Camera -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.ModelTraining +import androidx.compose.material.icons.rounded.PersonOutline import androidx.compose.material.icons.rounded.Phone import androidx.compose.material.icons.rounded.PhoneAndroid -import androidx.compose.material.icons.rounded.RadioButtonUnchecked -import androidx.compose.material.icons.rounded.ZoomIn import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues import de.gematik.ti.erp.app.BuildConfig import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.cardwall.usecase.deviceHasNFC +import de.gematik.ti.erp.app.mainscreen.ui.MainNavigationScreens import de.gematik.ti.erp.app.profiles.ui.Avatar -import de.gematik.ti.erp.app.profiles.ui.connectionText -import de.gematik.ti.erp.app.profiles.ui.connectionTextColor -import de.gematik.ti.erp.app.profiles.ui.profileColor import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.userauthentication.ui.BiometricPrompt -import de.gematik.ti.erp.app.utils.compose.AcceptDialog import de.gematik.ti.erp.app.utils.compose.AlertDialog -import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog -import de.gematik.ti.erp.app.utils.compose.LabeledSwitch -import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer24 +import de.gematik.ti.erp.app.utils.compose.OutlinedDebugButton import de.gematik.ti.erp.app.utils.compose.Spacer4 +import de.gematik.ti.erp.app.utils.compose.SpacerLarge import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import de.gematik.ti.erp.app.utils.compose.SpacerTiny -import de.gematik.ti.erp.app.utils.compose.createToastShort import de.gematik.ti.erp.app.utils.compose.handleIntent import de.gematik.ti.erp.app.utils.compose.navigationModeState +import de.gematik.ti.erp.app.utils.compose.provideEmailIntent import de.gematik.ti.erp.app.utils.compose.providePhoneIntent -import de.gematik.ti.erp.app.utils.compose.provideWebIntent -import de.gematik.ti.erp.app.utils.compose.testId +import de.gematik.ti.erp.app.utils.sanitizeProfileName import java.util.Locale -import de.gematik.ti.erp.app.utils.dateTimeShortText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.withContext @Composable fun SettingsScreen( - scrollTo: SettingsScrollTo, mainNavController: NavController, - settingsViewModel: SettingsViewModel = hiltViewModel() + settingsViewModel: SettingsViewModel ) { val settingsNavController = rememberNavController() - val navigationMode by settingsNavController.navigationModeState( - SettingsNavigationScreens.Settings.route, - intercept = { previousRoute: String?, currentRoute: String? -> - if (previousRoute == SettingsNavigationScreens.OrderHealthCard.route && currentRoute == SettingsNavigationScreens.Settings.route) { - NavigationMode.Closed - } else { - null - } - } - ) + val navigationMode by settingsNavController.navigationModeState(SettingsNavigationScreens.Settings.route) SettingsNavGraph( - settingsNavController, - navigationMode, - scrollTo, - mainNavController, - settingsViewModel + settingsNavController = settingsNavController, + navigationMode = navigationMode, + mainNavController = mainNavController, + settingsViewModel = settingsViewModel ) } @Composable fun SettingsScreenWithScaffold( - scrollTo: SettingsScrollTo, mainNavController: NavController, navController: NavController, settingsViewModel: SettingsViewModel @@ -179,127 +138,106 @@ fun SettingsScreenWithScaffold( } } - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - stringResource(R.string.settings_headline) - ) { mainNavController.popBackStack() } - } - ) { - val listState = rememberLazyListState() - var showAllowScreenShotsAlert by remember { mutableStateOf(false) } + val listState = rememberLazyListState() - LaunchedEffect(Unit) { - delay(200) - when (scrollTo) { - // TODO: find another way to scoll to Item - SettingsScrollTo.None -> { - /* noop */ - } - SettingsScrollTo.Authentication -> listState.animateScrollToItem(5) - SettingsScrollTo.DemoMode -> listState.animateScrollToItem(3) - SettingsScrollTo.Profiles -> listState.animateScrollToItem(2) - } - } + Scaffold( + modifier = Modifier + .testTag(TestTag.Settings.SettingsScreen) + .statusBarsPadding() + ) { contentPadding -> LazyColumn( modifier = Modifier.testTag("settings_screen"), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ), + contentPadding = contentPadding, state = listState ) { - item { - if (BuildKonfig.INTERNAL) { - DebugMenuSection(navController) - SettingsDivider() + if (BuildKonfig.INTERNAL) { + item { + DebugMenuSection(mainNavController) } } item { - OrderHealthCardHint( - modifier = Modifier.fillMaxWidth(), - onClick = { - navController.navigate(SettingsNavigationScreens.OrderHealthCard.path()) - } - ) - SettingsDivider() - } - item { - ProfileSection(state, settingsViewModel, navController) + ProfileSection(state, mainNavController) SettingsDivider() } item { - DemoSection( - state.demoModeActive, - highlighted = scrollTo == SettingsScrollTo.DemoMode, - ) { - if (it) { - settingsViewModel.onActivateDemoMode() - } else { - settingsViewModel.onDeactivateDemoMode() + HealthCardSection( + onClickUnlockEgk = { unlockMethod -> + mainNavController.navigate( + MainNavigationScreens.UnlockEgk.path( + unlockMethod = unlockMethod + ) + ) + }, + onClickOrderHealthCard = { + mainNavController.navigate(MainNavigationScreens.OrderHealthCard.path()) } - } - SettingsDivider() - } - item { - AccessibilitySection(zoomChecked = state.zoomEnabled) { - when (it) { - true -> settingsViewModel.onEnableZoom() - false -> settingsViewModel.onDisableZoom() - } - } - SettingsDivider() - } - item { - AuthenticationSection(state.authenticationMode) { - when (it) { - SettingsScreen.AuthenticationMode.Password -> navController.navigate("Password") - else -> settingsViewModel.onSelectDeviceSecurityAuthenticationMode() - } - } + ) SettingsDivider() } item { - val context = LocalContext.current - val disAllowToast = stringResource(R.string.settings_tracking_disallow_info) - AnalyticsSection( - state.analyticsAllowed - ) { - if (!it) { - settingsViewModel.onTrackingDisallowed() - createToastShort(context, disAllowToast) - } else { - navController.navigate(SettingsNavigationScreens.AllowAnalytics.path()) + GlobalSettingsSection( + onClickAccessibilitySettings = { + navController.navigate(SettingsNavigationScreens.AccessibilitySettings.path()) + }, + onClickProductImprovementSettings = { + navController.navigate(SettingsNavigationScreens.ProductImprovementSettings.path()) + }, + onClickDeviceSecuritySettings = { + navController.navigate(SettingsNavigationScreens.DeviceSecuritySettings.path()) } - } - SettingsDivider() - } - - item { - AllowScreenShotsSection( - state.screenShotsAllowed - ) { - settingsViewModel.onSwitchAllowScreenshots(it) - showAllowScreenShotsAlert = true - } + ) SettingsDivider() } - item { - ContactSection(onClickFeedback = { navController.navigate(SettingsNavigationScreens.FeedbackForm.path()) }) + ContactSection() SettingsDivider() } item { - LegalSection(navController) + LegalSection(mainNavController) } item { AboutSection(Modifier.padding(top = 76.dp)) } } - if (showAllowScreenShotsAlert) { - RestartAlert { showAllowScreenShotsAlert = false } + } +} + +@Composable +fun GlobalSettingsSection( + onClickAccessibilitySettings: () -> Unit, + onClickProductImprovementSettings: () -> Unit, + onClickDeviceSecuritySettings: () -> Unit + +) { + Column { + Text( + text = stringResource(R.string.settings_personal_settings_header), + style = AppTheme.typography.h6, + modifier = Modifier.padding( + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium / 2, + top = PaddingDefaults.Medium + ) + ) + LabelButton( + Icons.Outlined.AccessibilityNew, + stringResource(R.string.settings_accessibility_header) + ) { + onClickAccessibilitySettings() + } + LabelButton( + Icons.Outlined.Timeline, + stringResource(R.string.settings_product_improvement_header) + ) { + onClickProductImprovementSettings() + } + LabelButton( + Icons.Outlined.Security, + stringResource(R.string.settings_device_security_header) + ) { + onClickDeviceSecuritySettings() } } } @@ -312,26 +250,17 @@ private fun SettingsDivider() = .padding(top = 8.dp, bottom = 8.dp) ) -@OptIn(ExperimentalAnimationApi::class) @Composable private fun ProfileSection( state: SettingsScreen.State, - viewModel: SettingsViewModel, - navController: NavController, + navController: NavController ) { - val profiles = state.uiProfiles - - var showAddProfileDialog by remember { mutableStateOf(false) } - val allowAddProfiles by produceState(initialValue = false) { - viewModel.allowAddProfiles().collect { - value = it - } - } + val profiles = state.profiles Column { Text( text = stringResource(R.string.settings_profiles_headline), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier .padding( start = PaddingDefaults.Medium, @@ -344,127 +273,62 @@ private fun ProfileSection( profiles.forEach { profile -> ProfileCard( - demoModeActive = state.demoModeActive, profile = profile, - onSwitchProfile = { viewModel.switchProfile(profile) }, - onClickEdit = { navController.navigate(SettingsNavigationScreens.EditProfile.path(profileId = profile.id)) } + onClickEdit = { navController.navigate(MainNavigationScreens.EditProfile.path(profileId = profile.id)) } ) } } - - if (showAddProfileDialog) { - AddProfileDialog( - state = state, - onEdit = { viewModel.addProfile(it); showAddProfileDialog = false }, - onDismissRequest = { showAddProfileDialog = false } - ) - } - - val context = LocalContext.current - val demoToastText = stringResource(R.string.function_not_availlable_on_demo_mode) - val addProfilesNotAllowedText = stringResource(R.string.settings_add_profile_not_allowed) - - AddProfile(onClick = { - if (!state.demoModeActive && allowAddProfiles) - showAddProfileDialog = true - else { - if (!allowAddProfiles) createToastShort(context, addProfilesNotAllowedText) - else createToastShort(context, demoToastText) - } - }) - Spacer24() + SpacerLarge() } @Composable private fun ProfileCard( - demoModeActive: Boolean, profile: ProfilesUseCaseData.Profile, - onSwitchProfile: () -> Unit, - onClickEdit: () -> Unit, + onClickEdit: () -> Unit ) { - val colors = profileColor(profileColorNames = profile.color) - val profileSsoToken = profile.ssoToken - Row( modifier = Modifier .fillMaxWidth() - .clickable { - if (!demoModeActive) { - onSwitchProfile() - } - }, - horizontalArrangement = Arrangement.spacedBy(8.dp), + .clickable(role = Role.Button) { + onClickEdit() + } + .padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.ShortMedium) + .testTag(TestTag.Settings.ProfileButton), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .weight(1f) - .padding(PaddingDefaults.Medium) - ) { - Avatar(profile.name, colors, null, active = profile.active) - - SpacerSmall() - - Column { - Text( - profile.name, style = MaterialTheme.typography.body1, - ) - - val lastAuthenticatedDateText = - remember(profile.lastAuthenticated) { profile.lastAuthenticated?.let { dateTimeShortText(it) } } - val connectedText = connectionText(profileSsoToken, lastAuthenticatedDateText) - val connectedColor = connectionTextColor(profileSsoToken) - - Text( - connectedText, style = AppTheme.typography.captionl, - color = connectedColor, - ) - } - } - - val context = LocalContext.current - val demoToastText = stringResource(R.string.function_not_availlable_on_demo_mode) - - IconButton( - onClick = { - if (!demoModeActive) { - onClickEdit() - } else { - createToastShort(context, demoToastText) - } - } - ) { - Icon(Icons.Outlined.Edit, null, tint = AppTheme.colors.neutral400) - } - - SpacerTiny() + Avatar( + avatarModifier = Modifier.size(48.dp), + emptyIcon = Icons.Rounded.PersonOutline, + profile = profile, + ssoStatusColor = null, + iconModifier = Modifier.size(20.dp) + ) + SpacerMedium() + Text( + modifier = Modifier.weight(1f), + text = profile.name, + style = AppTheme.typography.body1 + ) + Icon(Icons.Outlined.KeyboardArrowRight, null, tint = AppTheme.colors.neutral400) } } -@Composable -private fun AddProfile( - onClick: () -> Unit -) { - TextButton(onClick = { onClick() }, contentPadding = PaddingValues(PaddingDefaults.Medium)) { - Icon(Icons.Rounded.Add, null) - SpacerSmall() - Text( - stringResource(R.string.settings_add_profile), - style = MaterialTheme.typography.body1, - modifier = Modifier.weight(1.0f) - ) -} -} - @OptIn(ExperimentalComposeUiApi::class) @Composable -fun AddProfileDialog( - state: SettingsScreen.State, +fun ProfileNameDialog( + initialProfileName: String = "", + settingsViewModel: SettingsViewModel, wantRemoveLastProfile: Boolean = false, onEdit: (text: String) -> Unit, onDismissRequest: () -> Unit ) { - var textValue by remember { mutableStateOf("") } + val settingsScreenState by produceState(SettingsScreen.defaultState) { + settingsViewModel.screenState().collect { + value = it + } + } + var textValue by remember { mutableStateOf(initialProfileName ?: "") } var duplicated by remember { mutableStateOf(false) } val title = if (wantRemoveLastProfile) { @@ -475,15 +339,18 @@ fun AddProfileDialog( val infoText = if (wantRemoveLastProfile) { stringResource(R.string.profile_edit_name_for_default_info) + } else if (initialProfileName.isNotEmpty()) { + stringResource(R.string.profile_edit_name_for_rename_info) } else { stringResource(R.string.profile_edit_name_info) } AlertDialog( + modifier = Modifier.testTag(TestTag.Settings.AddProfileDialog.Modal), title = { Text( title, - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1 ) }, properties = DialogProperties(dismissOnClickOutside = false), @@ -492,15 +359,19 @@ fun AddProfileDialog( Column { Text( infoText, - style = MaterialTheme.typography.body2 + style = AppTheme.typography.body2 ) Box(modifier = Modifier.padding(top = 12.dp)) { OutlinedTextField( + modifier = Modifier.testTag(TestTag.Settings.AddProfileDialog.ProfileNameTextField), value = textValue, singleLine = true, onValueChange = { - textValue = it.trimStart() - duplicated = state.containsProfileWithName(textValue) + val name = sanitizeProfileName(it.trimStart()) + textValue = name + duplicated = textValue.trim() != initialProfileName && + settingsScreenState.containsProfileWithName(textValue) && + !wantRemoveLastProfile }, keyboardOptions = KeyboardOptions( autoCorrect = true, @@ -508,7 +379,7 @@ fun AddProfileDialog( imeAction = ImeAction.Done ), keyboardActions = KeyboardActions { - if (textValue.isNotEmpty()) { + if (!duplicated && textValue.isNotEmpty()) { onEdit(textValue) } }, @@ -520,21 +391,26 @@ fun AddProfileDialog( Text( stringResource(R.string.edit_profile_duplicated_profile_name), color = AppTheme.colors.red600, - style = MaterialTheme.typography.caption, + style = AppTheme.typography.caption1, modifier = Modifier.padding(start = PaddingDefaults.Medium) ) } } }, buttons = { - TextButton(onClick = { onDismissRequest() }) { + TextButton( + modifier = Modifier.testTag(TestTag.Settings.AddProfileDialog.CancelButton), + onClick = { onDismissRequest() } + ) { Text(stringResource(R.string.cancel).uppercase(Locale.getDefault())) } - TextButton(onClick = { - if (!duplicated && textValue.isNotEmpty()) { + TextButton( + modifier = Modifier.testTag(TestTag.Settings.AddProfileDialog.ConfirmButton), + enabled = !duplicated && textValue.isNotEmpty(), + onClick = { onEdit(textValue) } - }) { + ) { Text(stringResource(R.string.ok).uppercase(Locale.getDefault())) } } @@ -549,323 +425,64 @@ fun AddProfileDialog( } @Composable -private fun DemoSection( - demoChecked: Boolean, - modifier: Modifier = Modifier, - highlighted: Boolean, - onDemoChange: (Boolean) -> Unit, -) { - var toggle by remember { mutableStateOf(false) } - val transition = updateTransition(targetState = toggle, label = "DemoSectionTransition") - - val color by transition.animateColor( - transitionSpec = { - repeatable( - 5, - tween(1000), - RepeatMode.Reverse - ) - }, - label = "DemoSectionColorAnimation" - ) { - if (it) AppTheme.colors.yellow300 - else MaterialTheme.colors.background - } - - LaunchedEffect(highlighted) { - toggle = highlighted - } - - Column(modifier = modifier.background(color)) { - Column( - modifier = Modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.settings_demo_headline), - style = MaterialTheme.typography.h6, - modifier = Modifier.testId("stg_txt_header_demo_mode") - ) - Text( - text = stringResource(R.string.settings_demo_info), - style = AppTheme.typography.body2l - ) - } - LabeledSwitch( - checked = demoChecked, - onCheckedChange = onDemoChange, - modifier = Modifier.testId("stg_btn_demo_mode") - ) { - Text( - modifier = Modifier.weight(1.0f), - text = stringResource(R.string.settings_demo_toggle), - style = MaterialTheme.typography.body1 - ) - } - } -} - -@Composable -private fun AccessibilitySection( - modifier: Modifier = Modifier, - zoomChecked: Boolean, - onZoomChange: (Boolean) -> Unit, -) { - Column(modifier = modifier) { - Column( - modifier = Modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), - ) { - Text( - text = stringResource(R.string.settings_accessibility_headline), - style = MaterialTheme.typography.h6 +fun HealthCardSection(onClickUnlockEgk: (unlockMethod: UnlockMethod) -> Unit, onClickOrderHealthCard: () -> Unit) { + Column { + Text( + text = stringResource(R.string.health_card_section_header), + style = AppTheme.typography.h6, + modifier = Modifier.padding( + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium / 2, + top = PaddingDefaults.Medium ) - } - LabeledSwitch( - checked = zoomChecked, - onCheckedChange = onZoomChange, - icon = Icons.Rounded.ZoomIn, - header = stringResource(R.string.settings_accessibility_zoom_toggle), - description = stringResource(R.string.settings_accessibility_zoom_info) ) - } -} -@Preview -@Composable -private fun DemoSectionPreview() { - AppTheme { - DemoSection(true, Modifier, false) {} - } -} - -@Composable -private fun AuthenticationSection( - authenticationMode: SettingsScreen.AuthenticationMode, - modifier: Modifier = Modifier, - onClickProtectionMode: (SettingsScreen.AuthenticationMode) -> Unit -) { - - var showBiometricPrompt by rememberSaveable { mutableStateOf(false) } - - if (showBiometricPrompt) { - BiometricPrompt( - authenticationMethod = SettingsAuthenticationMethod.DeviceSecurity, - title = stringResource(R.string.auth_prompt_headline), - description = "", - negativeButton = stringResource(R.string.auth_prompt_cancel), - onAuthenticated = { - onClickProtectionMode(SettingsScreen.AuthenticationMode.DeviceSecurity) - showBiometricPrompt = false - }, - onCancel = { - showBiometricPrompt = false - }, - onAuthenticationError = { - showBiometricPrompt = false - }, - onAuthenticationSoftError = { - } - ) - } - - Column(modifier = modifier) { - Column( - modifier = Modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(8.dp) + LabelButton( + modifier = Modifier.testTag(TestTag.Settings.OrderNewCardButton), + icon = painterResource(R.drawable.ic_order_egk), + text = stringResource(R.string.health_card_section_order_card) ) { - Text( - text = stringResource(R.string.settings_appprotection_headline), - style = MaterialTheme.typography.h6 - ) - Text( - text = stringResource(R.string.settings_appprotection_info), - style = MaterialTheme.typography.body2 - ) + onClickOrderHealthCard() } - AuthenticationModeCard( - Icons.Outlined.Fingerprint, - checked = authenticationMode == SettingsScreen.AuthenticationMode.DeviceSecurity, - headline = stringResource(R.string.settings_appprotection_device_security_header), - info = stringResource(R.string.settings_appprotection_device_security_info), - deviceSecurity = true, + LabelButton( + Icons.Outlined.HelpOutline, + stringResource(R.string.health_card_section_unlock_card_forgot_pin) ) { - showBiometricPrompt = true + onClickUnlockEgk(UnlockMethod.ResetRetryCounterWithNewSecret) } - AuthenticationModeCard( - Icons.Outlined.Security, - checked = authenticationMode == SettingsScreen.AuthenticationMode.Password, - headline = stringResource(R.string.settings_appprotection_mode_password_headline), - info = stringResource(R.string.settings_appprotection_mode_password_info) + LabelButton( + painterResource(R.drawable.ic_reset_pin), + stringResource(R.string.health_card_section_unlock_card_reset_pin) ) { - onClickProtectionMode(SettingsScreen.AuthenticationMode.Password) - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun AuthenticationModeCard( - icon: ImageVector, - checked: Boolean, - headline: String, - info: String, - deviceSecurity: Boolean = false, - enabled: Boolean = true, - onClick: () -> Unit -) { - - var showAllowDeviceSecurity by remember { mutableStateOf(false) } - - if (deviceSecurity && showAllowDeviceSecurity && !checked) { - CommonAlertDialog( - header = stringResource(R.string.settings_biometric_dialog_title), - info = stringResource(R.string.settings_biometric_dialog_text), - actionText = stringResource(R.string.settings_device_security_allow), - cancelText = stringResource(R.string.cancel), - onCancel = { showAllowDeviceSecurity = false }, - onClickAction = { - onClick() - showAllowDeviceSecurity = false - } - ) - } - - val alpha = remember { Animatable(0.0f) } - - LaunchedEffect(checked) { - if (checked) { - alpha.animateTo(1.0f) - } else { - alpha.animateTo(0.0f) - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .clickable( - onClick = { - if (deviceSecurity) { - showAllowDeviceSecurity = true - } else { - onClick() - } - }, - enabled = enabled - ) - .padding(PaddingDefaults.Medium) - ) { - Icon(icon, null, tint = AppTheme.colors.primary500) - Column(modifier = Modifier.weight(1.0f)) { - Text( - text = headline, - style = MaterialTheme.typography.body1, - ) - Text( - text = info, - style = AppTheme.typography.body2l - ) + onClickUnlockEgk(UnlockMethod.ChangeReferenceData) } - Box(modifier = Modifier.align(Alignment.CenterVertically)) { - Icon( - Icons.Rounded.RadioButtonUnchecked, null, - tint = AppTheme.colors.neutral400 - ) - Icon( - Icons.Rounded.CheckCircle, null, - tint = AppTheme.colors.primary600, - modifier = Modifier.alpha(alpha.value) - ) + LabelButton( + Icons.Outlined.LockOpen, + stringResource(R.string.health_card_section_unlock_card_no_reset) + ) { + onClickUnlockEgk(UnlockMethod.ResetRetryCounter) } } } @Composable private fun DebugMenuSection(navController: NavController) { - Text( + OutlinedDebugButton( text = stringResource(id = R.string.debug_menu), - style = MaterialTheme.typography.h6, + onClick = { navController.navigate(MainNavigationScreens.Debug.path()) }, modifier = Modifier + .fillMaxWidth() .padding( start = PaddingDefaults.Medium, end = PaddingDefaults.Medium, bottom = PaddingDefaults.Medium / 2, top = PaddingDefaults.Medium ) - .clickable { - navController.navigate(SettingsNavigationScreens.Debug.path()) - } - .testId("stg_btn_debug_menu") - ) -} - -@Composable -private fun AnalyticsSection( - analyticsAllowed: Boolean, - modifier: Modifier = Modifier, - onCheckedChange: (Boolean) -> Unit -) { - - Column { - Column( - modifier = modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.settings_tracking_headline), - style = MaterialTheme.typography.h6 - ) - Text( - text = stringResource(R.string.settings_tracking_info), - style = MaterialTheme.typography.body2 - ) - } - LabeledSwitch( - checked = analyticsAllowed, - onCheckedChange = onCheckedChange, - modifier = modifier - .testId("settings/trackingToggle"), - icon = Icons.Rounded.ModelTraining, - header = stringResource(R.string.settings_tracking_toggle_text), - description = stringResource(R.string.settings_tracking_description) - ) - } -} - -@Composable -private fun AllowScreenShotsSection( - allowScreenshots: Boolean, - modifier: Modifier = Modifier, - onAllowScreenshotsChange: (Boolean) -> Unit, -) { - LabeledSwitch( - checked = !allowScreenshots, - onCheckedChange = { - onAllowScreenshotsChange(!it) - }, - modifier = modifier - .testId("settings/allowScreenshotsToggle"), - icon = Icons.Rounded.Camera, - header = stringResource(R.string.settings_screenshots_toggle_text), - description = stringResource(R.string.settings_screenshots_description) - ) -} - -@Composable -private fun RestartAlert(onDismissRequest: () -> Unit) { - val title = stringResource(R.string.settings_screenshots_alert_headline) - val message = stringResource(R.string.settings_screenshots_alert_info) - val confirmText = stringResource(R.string.settings_screenshots_button_text) - - AcceptDialog( - header = title, - onClickAccept = onDismissRequest, - info = message, - acceptText = confirmText + .testTag(TestTag.Settings.DebugMenuButton) ) } @@ -874,7 +491,7 @@ private fun LegalSection(navController: NavController) { Column { Text( text = stringResource(R.string.settings_legal_headline), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier.padding( start = PaddingDefaults.Medium, end = PaddingDefaults.Medium, @@ -885,30 +502,37 @@ private fun LegalSection(navController: NavController) { LabelButton( Icons.Outlined.Info, stringResource(R.string.settings_legal_imprint), - modifier = Modifier.testId("settings/imprint") + modifier = Modifier.testTag("settings/imprint") ) { - navController.navigate(SettingsNavigationScreens.Imprint.route) + navController.navigate(MainNavigationScreens.Imprint.route) } LabelButton( Icons.Outlined.PrivacyTip, stringResource(R.string.settings_legal_dataprotection), - modifier = Modifier.testId("settings/privacy") + modifier = Modifier.testTag("settings/privacy") ) { - navController.navigate(SettingsNavigationScreens.DataProtection.route) + navController.navigate(MainNavigationScreens.DataProtection.route) } LabelButton( Icons.Outlined.Wysiwyg, stringResource(R.string.settings_legal_tos), - modifier = Modifier.testId("settings/tos") + modifier = Modifier.testTag("settings/tos") ) { - navController.navigate(SettingsNavigationScreens.Terms.route) + navController.navigate(MainNavigationScreens.Terms.route) } LabelButton( - Icons.Outlined.PrivacyTip, + Icons.Outlined.Code, stringResource(R.string.settings_legal_licences), - modifier = Modifier.testId("settings/licences") + modifier = Modifier.testTag("settings/licences") + ) { + navController.navigate(MainNavigationScreens.OpenSourceLicences.route) + } + LabelButton( + Icons.Outlined.Source, + stringResource(R.string.settings_licence_pharmacy_search), + modifier = Modifier.testTag("settings/additional_licences") ) { - navController.navigate(SettingsNavigationScreens.OpenSourceLicences.route) + navController.navigate(MainNavigationScreens.AdditionalLicences.route) } } } @@ -921,7 +545,7 @@ private fun LabelButton( onClick: () -> Unit ) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() @@ -929,8 +553,41 @@ private fun LabelButton( .padding(PaddingDefaults.Medium) .semantics(mergeDescendants = true) {} ) { - Icon(icon, null, tint = AppTheme.colors.primary500) - Text(text, style = MaterialTheme.typography.body1) + Icon(icon, null, tint = AppTheme.colors.primary600) + SpacerMedium() + Text( + modifier = Modifier.weight(1f), + text = text, + style = AppTheme.typography.body1 + ) + Icon(Icons.Outlined.KeyboardArrowRight, null, tint = AppTheme.colors.neutral400) + } +} + +@Composable +private fun LabelButton( + icon: Painter, + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(PaddingDefaults.Medium) + .semantics(mergeDescendants = true) {} + ) { + Image(painter = icon, contentDescription = null) + SpacerMedium() + Text( + modifier = Modifier.weight(1f), + text = text, + style = AppTheme.typography.body1 + ) + Icon(Icons.Outlined.KeyboardArrowRight, null, tint = AppTheme.colors.neutral400) } } @@ -943,7 +600,7 @@ private fun AboutSection(modifier: Modifier) { horizontalAlignment = Alignment.CenterHorizontally ) { CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.body2, + LocalTextStyle provides AppTheme.typography.body2, LocalContentColor provides AppTheme.colors.neutral600 ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -962,118 +619,79 @@ private fun AboutSection(modifier: Modifier) { } @Composable -private fun LogoutButton(onClick: () -> Unit) { - var dialogVisible by remember { mutableStateOf(false) } - if (dialogVisible) { - - CommonAlertDialog( - header = stringResource(id = R.string.logout_detail_header), - info = stringResource(R.string.logout_detail_message), - actionText = stringResource(R.string.logout_delete_yes), - cancelText = stringResource(R.string.logout_delete_no), - onCancel = { dialogVisible = false }, - onClickAction = { - onClick() - dialogVisible = false - } - ) - } - - Button( - onClick = { dialogVisible = true }, - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp, - top = 32.dp, - bottom = 16.dp - ) - .fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.red600, - contentColor = AppTheme.colors.neutral000 - ) - ) { - Text( - stringResource(R.string.logout).uppercase(Locale.getDefault()), - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 8.dp - ) - ) - } - Text( - stringResource(R.string.logout_description), - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - bottom = PaddingDefaults.Small - ), - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center - ) -} - -@Composable -private fun ContactSection(onClickFeedback: () -> Unit) { +private fun ContactSection() { val context = LocalContext.current val contactHeader = stringResource(R.string.settings_contact_headline) Column { val phoneNumber = stringResource(R.string.settings_contact_hotline_number) - val feedbackAddress = stringResource(R.string.settings_contact_feedback_adress) + val mailAddress = stringResource(R.string.settings_contact_mail_address) + val subject = stringResource(R.string.settings_feedback_mail_subject) + val body = buildFeedbackBodyWithDeviceInfo(context = context) + SpacerMedium() Text( text = contactHeader, - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) ) SpacerSmall() - LabelButton( - icon = Icons.Rounded.Phone, - text = stringResource(R.string.settings_contact_hotline), - onClick = { context.handleIntent(providePhoneIntent(phoneNumber)) } - ) + LabelButton( icon = Icons.Outlined.Mail, text = stringResource(R.string.settings_contact_feedback_form), - onClick = { onClickFeedback() } + onClick = { + openMailClient(context, mailAddress, body, subject) + } ) LabelButton( - icon = Icons.Outlined.OpenInBrowser, - text = stringResource(R.string.settings_contact_feedback), - onClick = { context.handleIntent(provideWebIntent(feedbackAddress)) } + icon = Icons.Rounded.Phone, + text = stringResource(R.string.settings_contact_hotline), + onClick = { context.handleIntent(providePhoneIntent(phoneNumber)) } + ) + Text( + text = stringResource(R.string.settings_contact_technical_support_description), + style = AppTheme.typography.body2l, + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) ) } } -@Composable -fun secureOptionEnabled(): Boolean { - val context = LocalContext.current - - return produceState(false) { - withContext(Dispatchers.Main) { - val biometricManager = BiometricManager.from(context) - value = secureOptionEnabled(biometricManager) - } - }.value -} - -private fun secureOptionEnabled(biometricManager: BiometricManager): Boolean { +fun openMailClient( + context: Context, + address: String, + body: String, + subject: String +) = context.handleIntent( + provideEmailIntent( + address = address, + body = body, + subject = subject + ) +) - when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { - BiometricManager.BIOMETRIC_SUCCESS -> return true - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> return false - } - when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) { - BiometricManager.BIOMETRIC_SUCCESS -> return true - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> return false - } - when (biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL)) { - BiometricManager.BIOMETRIC_SUCCESS -> return true - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> return false - } - return false -} +@Suppress("MaxLineLength") +@Composable +fun buildFeedbackBodyWithDeviceInfo( + context: Context, + title: String = stringResource(R.string.settings_feedback_mail_title), + userHint: String = stringResource(R.string.seetings_feedback_form_additional_data_info), + errorState: String? = null, + darkMode: Boolean = isSystemInDarkTheme() +): String = """$title + | + | + | + |$userHint + | + |Systeminformationen + | + |Betriebssystem: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}) (PATCH ${Build.VERSION.SECURITY_PATCH}) + |Modell: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.PRODUCT}) + |App Version: ${BuildConfig.VERSION_NAME} (${BuildKonfig.GIT_HASH}) + |DarkMode: ${if (darkMode) "an" else "aus"} + |Sprache: ${Locale.getDefault().displayName} + |FehlerStatus: ${errorState ?: ""} + |NFC: ${if (context.deviceHasNFC()) "vorhanden" else "nicht vorhanden"} + | +""".trimMargin() diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsViewModel.kt index 16eda34e..598e6717 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/SettingsViewModel.kt @@ -23,133 +23,104 @@ import androidx.compose.runtime.Immutable import androidx.core.content.edit import androidx.lifecycle.viewModelScope import androidx.paging.PagingData -import dagger.hilt.android.lifecycle.HiltViewModel import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.SCREENSHOTS_ALLOWED -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.di.ApplicationPreferences -import de.gematik.ti.erp.app.featuretoggle.FeatureToggleManager -import de.gematik.ti.erp.app.featuretoggle.Features +import de.gematik.ti.erp.app.ScreenshotsAllowed +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.ProfilesWithPairedDevicesUseCase import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.protocol.model.AuditEventData +import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase -import de.gematik.ti.erp.app.tracking.Tracker +import de.gematik.ti.erp.app.analytics.Analytics import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.time.LocalDate -import javax.inject.Inject object SettingsScreen { - enum class AuthenticationMode { - EHealthCard, - DeviceSecurity, - - @Deprecated("replaced by deviceSecurity") - Biometrics, - - @Deprecated("replaced by deviceSecurity") - DeviceCredentials, - Password, - - @Deprecated("not available anymore") - None, - Unspecified - } - @Immutable data class State( - val demoModeActive: Boolean, val analyticsAllowed: Boolean, - val authenticationMode: AuthenticationMode, + val authenticationMode: SettingsData.AuthenticationMode, val zoomEnabled: Boolean, - val screenShotsAllowed: Boolean, - val uiProfiles: List + val screenshotsAllowed: Boolean, + val profiles: List ) { - fun activeProfile() = uiProfiles.find { it.active }!! - fun profileById(profileId: Int) = uiProfiles.find { it.id == profileId } - fun containsProfileWithName(name: String) = uiProfiles.any { + fun activeProfile() = profiles.find { it.active }!! + fun profileById(profileId: String) = profiles.find { it.id == profileId } + fun containsProfileWithName(name: String) = profiles.any { it.name.equals(name.trim(), true) } } val defaultState = State( - demoModeActive = false, analyticsAllowed = false, - authenticationMode = AuthenticationMode.Unspecified, + authenticationMode = SettingsData.AuthenticationMode.Unspecified, zoomEnabled = false, // `gemSpec_eRp_FdV A_20203` default settings does not allow screenshots - screenShotsAllowed = false, - uiProfiles = listOf() + screenshotsAllowed = false, + profiles = listOf() ) } -const val NEW_USER = "newUser" -const val UPDATED_DATA_TERMS_ACCEPTED = "UpdatedDataTermsAccepted" - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class SettingsViewModel @Inject constructor( +class SettingsViewModel( private val settingsUseCase: SettingsUseCase, private val profilesUseCase: ProfilesUseCase, - private val demoUseCase: DemoUseCase, - private val tracker: Tracker, - @ApplicationPreferences + private val profilesWithPairedDevicesUseCase: ProfilesWithPairedDevicesUseCase, + private val analytics: Analytics, private val appPrefs: SharedPreferences, - private val toggleManager: FeatureToggleManager, - private val coroutineDispatchProvider: DispatchProvider -) : BaseViewModel() { - - var isNewUser by settingsUseCase::isNewUser + private val dispatchers: DispatchProvider +) : ViewModel() { private var screenshotsAllowed = - MutableStateFlow(appPrefs.getBoolean(SCREENSHOTS_ALLOWED, false)) + MutableStateFlow(appPrefs.getBoolean(ScreenshotsAllowed, false)) fun screenState() = combine( - demoUseCase.demoModeActive, - tracker.trackingAllowed, - settingsUseCase.settings, + analytics.trackingAllowed, + settingsUseCase.general, + settingsUseCase.authenticationMode, screenshotsAllowed, - profilesUseCase.profiles, - ) { demoActive, analyticsAllowed, settings, screenshotsAllowed, uiProfiles -> + profilesUseCase.profiles + ) { analyticsAllowed, settings, authenticationMode, screenshotsAllowed, profiles -> SettingsScreen.State( - demoModeActive = demoActive, - analyticsAllowed = analyticsAllowed, - authenticationMode = when (settings.authenticationMethod) { - SettingsAuthenticationMethod.DeviceSecurity -> SettingsScreen.AuthenticationMode.DeviceSecurity - SettingsAuthenticationMethod.Password -> SettingsScreen.AuthenticationMode.Password - else -> SettingsScreen.AuthenticationMode.Unspecified - }, zoomEnabled = settings.zoomEnabled, - screenShotsAllowed = screenshotsAllowed, - uiProfiles = uiProfiles + analyticsAllowed = analyticsAllowed, + authenticationMode = authenticationMode, + screenshotsAllowed = screenshotsAllowed, + profiles = profiles ) - }.flowOn(coroutineDispatchProvider.default()) + }.flowOn(dispatchers.Default) + + fun pairedDevices(profileId: ProfileIdentifier) = + profilesWithPairedDevicesUseCase.pairedDevices(profileId) + + // tag::DeletePairedDevicesViewModel[] + suspend fun deletePairedDevice(profileId: ProfileIdentifier, device: ProfilesUseCaseData.PairedDevice) = + profilesWithPairedDevicesUseCase.deletePairedDevices(profileId, device) + + // end::DeletePairedDevicesViewModel[] + fun decryptedAccessToken(profile: ProfilesUseCaseData.Profile) = + profilesUseCase.decryptedAccessToken(profile.id) fun onSelectDeviceSecurityAuthenticationMode() = viewModelScope.launch(Dispatchers.IO) { - settingsUseCase.saveAuthenticationMethod( - SettingsAuthenticationMethod.DeviceSecurity + settingsUseCase.saveAuthenticationMode( + SettingsData.AuthenticationMode.DeviceSecurity ) } fun onSelectPasswordAsAuthenticationMode(password: String) = viewModelScope.launch(Dispatchers.IO) { - settingsUseCase.savePasswordAsAuthenticationMethod(password) + settingsUseCase.saveAuthenticationMode(SettingsData.AuthenticationMode.Password(password = password)) } fun onSwitchAllowScreenshots(allowScreenshots: Boolean) { appPrefs.edit { - putBoolean(SCREENSHOTS_ALLOWED, allowScreenshots) + putBoolean(ScreenshotsAllowed, allowScreenshots) } screenshotsAllowed.value = allowScreenshots } @@ -166,20 +137,12 @@ class SettingsViewModel @Inject constructor( } } - fun onActivateDemoMode() { - demoUseCase.activateDemoMode() - } - - fun onDeactivateDemoMode() { - demoUseCase.deactivateDemoMode() - } - fun onTrackingAllowed() { - tracker.allowTracking() + analytics.allowTracking() } fun onTrackingDisallowed() { - tracker.disallowTracking() + analytics.disallowTracking() } fun logout(profile: ProfilesUseCaseData.Profile) { @@ -194,53 +157,38 @@ class SettingsViewModel @Inject constructor( } } - fun overwriteDefaultProfile(profileName: String) { - viewModelScope.launch { - profilesUseCase.overwriteDefaultProfileName(profileName) - } - } - fun removeProfile(profile: ProfilesUseCaseData.Profile, newProfileName: String?) { viewModelScope.launch { if (newProfileName != null) { - profilesUseCase.removeProfile(profile, newProfileName) + profilesUseCase.removeAndSaveProfile(profile, newProfileName) } else { profilesUseCase.removeProfile(profile) } } } - fun updateProfileName(profile: ProfilesUseCaseData.Profile, newName: String) { - viewModelScope.launch { - profilesUseCase.updateProfileName(profile, newName) - } - } - - fun updateProfileColor(profile: ProfilesUseCaseData.Profile, color: ProfileColorNames) { - viewModelScope.launch { - profilesUseCase.updateProfileColor(profile, color) - } - } - fun switchProfile(profile: ProfilesUseCaseData.Profile) { viewModelScope.launch { profilesUseCase.switchActiveProfile(profile) } } - fun allowAddProfiles() = toggleManager.isFeatureEnabled(Features.ADD_PROFILE.featureName) - - fun isCanAvailable(profile: ProfilesUseCaseData.Profile) = - runBlocking { - profilesUseCase.isCanAvailable(profile).first() - } - - fun loadAuditEventsForProfile(profileName: String): Flow> = - profilesUseCase.loadAuditEventsForProfile(profileName) + fun loadAuditEventsForProfile(profileId: ProfileIdentifier): Flow> = + profilesUseCase.auditEvents(profileId) - fun acceptUpdatedDataTerms(date: LocalDate) { - viewModelScope.launch { - settingsUseCase.updatedDataTermsAccepted(date) + suspend fun onboardingSucceeded( + authenticationMode: SettingsData.AuthenticationMode, + defaultProfileName: String, + allowTracking: Boolean + ) { + settingsUseCase.onboardingSucceeded( + authenticationMode = authenticationMode, + defaultProfileName = defaultProfileName + ) + if (allowTracking) { + onTrackingAllowed() + } else { + onTrackingDisallowed() } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/TokenScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/TokenScreen.kt index f7a2ff84..845711ad 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/ui/TokenScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/TokenScreen.kt @@ -18,15 +18,21 @@ import android.widget.Toast import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Divider import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy @@ -38,61 +44,91 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.TestTag.Profile.TokenList.AccessToken +import de.gematik.ti.erp.app.TestTag.Profile.TokenList.NoTokenHeader +import de.gematik.ti.erp.app.TestTag.Profile.TokenList.NoTokenInfo +import de.gematik.ti.erp.app.TestTag.Profile.TokenList.SSOToken import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import de.gematik.ti.erp.app.utils.compose.visualTestTag @Composable fun TokenScreen(onBack: () -> Unit, ssoToken: String?, accessToken: String?) { val header = stringResource(id = R.string.token_headline) + val listState = rememberLazyListState() - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = header, - onBack = { onBack() } - ) - }, + AnimatedElevationScaffold( + modifier = Modifier.visualTestTag(TestTag.Profile.TokenList.TokenScreen), + navigationMode = NavigationBarMode.Back, + topBarTitle = header, + onBack = onBack, + listState = listState ) { val accessTokenTitle = stringResource(id = R.string.access_token_title) val singleSignOnTokenTitle = stringResource(id = R.string.single_sign_on_token_title) - LazyColumn( - modifier = Modifier.padding(vertical = PaddingDefaults.Medium), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) - ) { - item { - TokenLabel( - title = accessTokenTitle, - text = accessToken ?: stringResource(id = R.string.no_access_token), - tokenAvailable = accessToken != null - ) - Divider(modifier = Modifier.padding(start = PaddingDefaults.Medium)) + if (accessToken == null && ssoToken == null) { + LazyColumn( + state = listState, + modifier = Modifier + .padding(PaddingDefaults.Medium) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + stringResource(R.string.token_screen_no_token_header), + style = AppTheme.typography.subtitle1, + modifier = Modifier.visualTestTag(NoTokenHeader) + ) + SpacerSmall() + Text( + stringResource(R.string.token_screen_no_token_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center, + modifier = Modifier.visualTestTag(NoTokenInfo) + ) + } } - item { - TokenLabel( - title = singleSignOnTokenTitle, - text = ssoToken - ?: stringResource(id = R.string.no_single_sign_on_token), - tokenAvailable = ssoToken != null - ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.padding(vertical = PaddingDefaults.Medium), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() + ) { + item { + TokenLabel( + modifier = Modifier.visualTestTag(AccessToken), + title = accessTokenTitle, + text = accessToken ?: stringResource(id = R.string.no_access_token), + tokenAvailable = accessToken != null + ) + Divider(modifier = Modifier.padding(start = PaddingDefaults.Medium)) + } + item { + TokenLabel( + modifier = Modifier.visualTestTag(SSOToken), + title = singleSignOnTokenTitle, + text = ssoToken + ?: stringResource(id = R.string.no_single_sign_on_token), + tokenAvailable = ssoToken != null + ) + } } } } } @Composable -private fun TokenLabel(title: String, text: String, tokenAvailable: Boolean) { - +private fun TokenLabel(modifier: Modifier, title: String, text: String, tokenAvailable: Boolean) { val clipboardManager = LocalClipboardManager.current val context = LocalContext.current val copied = stringResource(R.string.copied) @@ -103,7 +139,7 @@ private fun TokenLabel(title: String, text: String, tokenAvailable: Boolean) { } val mod = if (tokenAvailable) { - Modifier + modifier .clickable(onClick = { if (tokenAvailable) { clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(text)) @@ -114,7 +150,7 @@ private fun TokenLabel(title: String, text: String, tokenAvailable: Boolean) { }) .semantics { contentDescription = description } } else { - Modifier + modifier } Row( @@ -129,10 +165,10 @@ private fun TokenLabel(title: String, text: String, tokenAvailable: Boolean) { .padding(PaddingDefaults.Medium) .weight(1f) ) { - Text(title, style = MaterialTheme.typography.subtitle1) - LazyColumn() { + Text(title, style = AppTheme.typography.subtitle1) + LazyColumn { item { - Text(text, style = MaterialTheme.typography.body2) + Text(text, style = AppTheme.typography.body2) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCase.kt index 7c6fe00c..7f036f49 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCase.kt @@ -20,35 +20,32 @@ package de.gematik.ti.erp.app.settings.usecase import android.app.KeyguardManager import android.content.Context -import android.content.SharedPreferences -import dagger.hilt.android.qualifiers.ApplicationContext import de.gematik.ti.erp.app.BuildKonfig -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import de.gematik.ti.erp.app.di.ApplicationPreferences +import de.gematik.ti.erp.app.profiles.usecase.sanitizedProfileName +import de.gematik.ti.erp.app.settings.GeneralSettings +import de.gematik.ti.erp.app.settings.PharmacySettings +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.settings.model.SettingsData.General import de.gematik.ti.erp.app.settings.repository.SettingsRepository -import de.gematik.ti.erp.app.settings.ui.NEW_USER +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.time.LocalDate -import javax.inject.Inject const val DEFAULT_PROFILE_NAME = "" -val DATA_PROTECTION_LAST_UPDATED: LocalDate = LocalDate.parse(BuildKonfig.DATA_PROTECTION_LAST_UPDATED) +val DATA_PROTECTION_LAST_UPDATED: Instant = + LocalDate.parse(BuildKonfig.DATA_PROTECTION_LAST_UPDATED).atStartOfDay().toInstant(ZoneOffset.UTC) -class SettingsUseCase @Inject constructor( - @ApplicationContext +class SettingsUseCase( private val context: Context, - private val settingsRepository: SettingsRepository, - @ApplicationPreferences - private val appPrefs: SharedPreferences, -) { - val settings = settingsRepository.settings() - - val zoomEnabled = - settings.map { it.zoomEnabled } + private val settingsRepository: SettingsRepository +) : GeneralSettings by settingsRepository, + PharmacySettings by settingsRepository { + // tag::ShowInsecureDevicePrompt[] val showInsecureDevicePrompt = - settings.map { + settingsRepository.general.map { val deviceSecured = (context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isDeviceSecure @@ -58,75 +55,27 @@ class SettingsUseCase @Inject constructor( false } } + // end::ShowInsecureDevicePrompt[] - val authenticationMethod = - settings.map { it.authenticationMethod } - - // TODO move to database - var isNewUser: Boolean - get() = appPrefs.getBoolean(NEW_USER, true) - set(v) { - appPrefs.edit().putBoolean(NEW_USER, v).apply() - } + val showOnboarding = settingsRepository.general.map { it.onboardingShownIn == null } + val showWelcomeDrawer = settingsRepository.general.map { !it.welcomeDrawerShown } var showDataTermsUpdate: Flow = - settings.map { - it.dataProtectionVersionAccepted < DATA_PROTECTION_LAST_UPDATED - } - - val pharmacySearch = - settings.map { it.pharmacySearch } - - suspend fun savePharmacySearch( - name: String, - locationEnabled: Boolean, - filterReady: Boolean, - filterDeliveryService: Boolean, - filterOnlineService: Boolean, - filterOpenNow: Boolean - ) { - settingsRepository.savePharmacySearch( - name = name, - locationEnabled = locationEnabled, - filterReady = filterReady, - filterDeliveryService = filterDeliveryService, - filterOnlineService = filterOnlineService, - filterOpenNow = filterOpenNow - ) - } - - suspend fun saveAuthenticationMethod(authenticationMethod: SettingsAuthenticationMethod) { - settingsRepository.saveAuthenticationMethod(authenticationMethod) - } + settingsRepository.general.map { it.dataProtectionVersionAcceptedOn < DATA_PROTECTION_LAST_UPDATED } - suspend fun savePasswordAsAuthenticationMethod(password: String) { - settingsRepository.savePasswordAsAuthenticationMethod(password) + suspend fun welcomeDrawerShown() { + settingsRepository.saveWelcomeDrawerShown() } + override val general: Flow + get() = settingsRepository.general - suspend fun saveZoomPreference(enabled: Boolean) { - settingsRepository.saveZoomPreference(enabled) - } - - suspend fun incrementNumberOfAuthenticationFailures() = - settingsRepository.incrementNumberOfAuthenticationFailures() - - suspend fun resetNumberOfAuthenticationFailures() = - settingsRepository.resetNumberOfAuthenticationFailures() - - suspend fun acceptInsecureDevice() = - settingsRepository.acceptInsecureDevice() - - suspend fun isPasswordValid(password: String): Boolean { - return settingsRepository.loadPassword()?.let { - settingsRepository.hashPasswordWithSalt(password, it.salt).contentEquals(it.hash) - } ?: false - } - - suspend fun updatedDataTermsAccepted(date: LocalDate) { - settingsRepository.updatedDataTermsAccepted(date) - } - - fun dataProtectionVersionAccepted(): Flow = settings.map { - it.dataProtectionVersionAccepted + suspend fun onboardingSucceeded( + authenticationMode: SettingsData.AuthenticationMode, + defaultProfileName: String, + now: Instant = Instant.now() + ) { + sanitizedProfileName(defaultProfileName)?.also { name -> + settingsRepository.saveOnboardingSucceededData(authenticationMode, name, now) + } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/theme/Shape.kt b/android/src/main/java/de/gematik/ti/erp/app/theme/Shape.kt index 4cd4604e..8f2d5302 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/theme/Shape.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/theme/Shape.kt @@ -23,8 +23,10 @@ import androidx.compose.ui.unit.dp object PaddingDefaults { val Tiny = 4.dp val Small = 8.dp + val ShortMedium = 12.dp val Medium = 16.dp val Large = 24.dp val XLarge = 32.dp val XXLarge = 40.dp + val XXLargeMedium = 56.dp } diff --git a/android/src/main/java/de/gematik/ti/erp/app/theme/Theme.kt b/android/src/main/java/de/gematik/ti/erp/app/theme/Theme.kt index d30158f0..d7065cf4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/theme/Theme.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/theme/Theme.kt @@ -19,17 +19,20 @@ package de.gematik.ti.erp.app.theme import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.Colors import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import de.gematik.ti.erp.app.R +@Suppress("LongMethod") @Composable fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colors = if (darkTheme) { @@ -38,32 +41,75 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () AppColorsThemeLight } + val fontFamily = remember { + FontFamily( + Font(R.font.noto_sans_bold, weight = FontWeight.Bold), + Font(R.font.noto_sans_medium, weight = FontWeight.Medium), + Font(R.font.noto_sans_regular, weight = FontWeight.Normal), + Font(R.font.noto_sans_semibold, weight = FontWeight.SemiBold) + ) + } + val typoColors = AppTypographyColors( + body1l = colors.neutral600, body2l = colors.neutral600, subtitle1l = colors.neutral600, subtitle2l = colors.neutral600, - captionl = colors.neutral600, + captionl = colors.neutral600 ) - MaterialTheme( - typography = MaterialTheme.typography.copy( - h1 = MaterialTheme.typography.h1.copy(lineHeight = 1.5.em), - h2 = MaterialTheme.typography.h2.copy(lineHeight = 1.5.em), - h3 = MaterialTheme.typography.h3.copy(lineHeight = 1.5.em), - h4 = MaterialTheme.typography.h4.copy(lineHeight = 1.5.em), - h5 = MaterialTheme.typography.h5.copy(lineHeight = 1.5.em), - h6 = MaterialTheme.typography.h6.copy(lineHeight = 1.5.em), - subtitle1 = MaterialTheme.typography.subtitle1.copy( - lineHeight = 1.5.em, - fontWeight = FontWeight.W500 - ), - subtitle2 = MaterialTheme.typography.subtitle2.copy( - lineHeight = 1.5.em, - fontWeight = FontWeight.W500 - ), - body1 = MaterialTheme.typography.body1.copy(lineHeight = 1.5.em), - body2 = MaterialTheme.typography.body2.copy(lineHeight = 1.5.em), + val materialTypo = MaterialTheme.typography.copy( + h1 = MaterialTheme.typography.h1.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W700 + ), + h2 = MaterialTheme.typography.h2.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W700 + ), + h3 = MaterialTheme.typography.h3.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W700 + ), + h4 = MaterialTheme.typography.h4.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W700 + ), + h5 = MaterialTheme.typography.h5.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W700 + ), + h6 = MaterialTheme.typography.h6.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em ), + subtitle1 = MaterialTheme.typography.subtitle1.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W500 + ), + subtitle2 = MaterialTheme.typography.subtitle2.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em, + fontWeight = FontWeight.W500 + ), + body1 = MaterialTheme.typography.body1.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em + ), + body2 = MaterialTheme.typography.body2.copy( + fontFamily = fontFamily, + lineHeight = 1.5.em + ) + ) + + MaterialTheme( + typography = materialTypo, colors = Colors( primary = colors.primary600, primaryVariant = colors.primary600, @@ -74,8 +120,8 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () error = colors.red500, onPrimary = colors.neutral000, onSecondary = colors.neutral000, - onBackground = colors.neutral999, - onSurface = colors.neutral999, + onBackground = colors.neutral900, + onSurface = colors.neutral900, onError = colors.red900, isLight = !darkTheme ), @@ -83,25 +129,44 @@ fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () val typo = AppTypography( body1l = MaterialTheme.typography.body1.copy( - color = colors.neutral600, + fontFamily = fontFamily, + color = typoColors.body1l, lineHeight = 1.5.em ), body2l = MaterialTheme.typography.body2.copy( + fontFamily = fontFamily, color = typoColors.body2l, lineHeight = 1.5.em ), subtitle1l = MaterialTheme.typography.subtitle1.copy( + fontFamily = fontFamily, color = typoColors.subtitle1l, lineHeight = 1.5.em ), subtitle2l = MaterialTheme.typography.subtitle2.copy( + fontFamily = fontFamily, color = typoColors.subtitle2l, lineHeight = 1.5.em ), - captionl = MaterialTheme.typography.caption.copy( + caption1l = MaterialTheme.typography.caption.copy( + fontFamily = fontFamily, color = typoColors.captionl, lineHeight = 1.5.em - ) + ), + h1 = materialTypo.h1, + h2 = materialTypo.h2, + h3 = materialTypo.h3, + h4 = materialTypo.h4, + h5 = materialTypo.h5, + h6 = materialTypo.h6, + subtitle1 = materialTypo.subtitle1, + subtitle2 = materialTypo.subtitle2, + body1 = materialTypo.body1, + body2 = materialTypo.body2, + button = materialTypo.button, + caption1 = materialTypo.caption, + caption2 = materialTypo.caption.copy(fontWeight = FontWeight.Medium), + overline = materialTypo.overline ) CompositionLocalProvider( @@ -127,8 +192,6 @@ object AppTheme { @Composable get() = LocalAppTypography.current - val framePadding = PaddingValues(16.dp) - val DebugColor = Color(0xFFD71F5F) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/theme/Type.kt b/android/src/main/java/de/gematik/ti/erp/app/theme/Type.kt index 8701edb8..9e09a737 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/theme/Type.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/theme/Type.kt @@ -16,6 +16,8 @@ * */ +@file:Suppress("LongParameterList") + package de.gematik.ti.erp.app.theme import androidx.compose.runtime.Immutable @@ -24,6 +26,7 @@ import androidx.compose.ui.text.TextStyle @Immutable data class AppTypographyColors( + val body1l: Color, val body2l: Color, val subtitle1l: Color, val subtitle2l: Color, @@ -31,10 +34,26 @@ data class AppTypographyColors( ) @Immutable -data class AppTypography( +class AppTypography( + // overloaded fonts with different lighter color val body1l: TextStyle, val body2l: TextStyle, val subtitle1l: TextStyle, val subtitle2l: TextStyle, - val captionl: TextStyle + val caption1l: TextStyle, + // material theme default fonts + val h1: TextStyle, + val h2: TextStyle, + val h3: TextStyle, + val h4: TextStyle, + val h5: TextStyle, + val h6: TextStyle, + val subtitle1: TextStyle, + val subtitle2: TextStyle, + val body1: TextStyle, + val body2: TextStyle, + val button: TextStyle, + val caption1: TextStyle, + val caption2: TextStyle, + val overline: TextStyle ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/tracking/Tracker.kt b/android/src/main/java/de/gematik/ti/erp/app/tracking/Tracker.kt deleted file mode 100644 index fbea506a..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/tracking/Tracker.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.tracking - -import android.content.Context -import android.net.Uri -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.core.content.edit -import androidx.navigation.NavHostController -import dagger.hilt.android.qualifiers.ApplicationContext -import de.gematik.ti.erp.app.BuildKonfig -import de.gematik.ti.erp.app.core.LocalTracker -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import pro.piwik.sdk.Piwik -import pro.piwik.sdk.Tracker -import pro.piwik.sdk.TrackerConfig -import pro.piwik.sdk.extra.TrackHelper -import pro.piwik.sdk.tools.Checksum -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -private const val trackerName = "Tracker" - -// `gemSpec_eRp_FdV A_20187` -@Singleton -class Tracker @Inject constructor( - @ApplicationContext private val context: Context -) { - private val _trackingAllowed = MutableStateFlow(false) - val trackingAllowed: StateFlow - get() = _trackingAllowed - - private val prefsName = "pro.piwik.sdk_" + Checksum.getMD5Checksum(trackerName) - private var tracker: Tracker = initTracker() - - private fun initTracker(): Tracker { - Timber.d("Init tracker") - - // otherwise piwik will query the advertisement id; piwik also doesn't expose this functionality in a more appropriate way - context.getSharedPreferences( - prefsName, - Context.MODE_PRIVATE - ).let { prefs -> - prefs.edit { - putBoolean("tracker.deviceid.on", false) - if (!prefs.contains("tracker.optout")) { - putBoolean("tracker.optout", true) - } - } - } - - return Piwik.getInstance(context).newTracker( - TrackerConfig( - BuildKonfig.PIWIK_TRACKER_URI, - BuildKonfig.PIWIK_TRACKER_ID, - trackerName - ) - ).apply { - // prevents piwik from creating cache files - offlineCacheAge = -1 - setDispatchInterval(0) - - _trackingAllowed.value = !isOptOut - } - } - - fun allowTracking() { - _trackingAllowed.value = true - tracker.isOptOut = false - - Timber.d("Tracking allowed") - } - - fun disallowTracking() { - tracker.preferences.edit(commit = true) { - clear() - } - tracker = initTracker() - } - - fun trackScreen(path: String) { - TrackHelper.track().screen(path).with(tracker) - } -} - -@Composable -fun TrackNavigationChanges(navController: NavHostController) { - val tracker = LocalTracker.current - - LaunchedEffect(Unit) { - navController.currentBackStackEntryFlow.collect { - try { - tracker.trackScreen(Uri.parse(it.destination.route).buildUpon().clearQuery().build().toString()) - } catch (e: Exception) { - Timber.e(e) - } - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/AuthenticationUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/AuthenticationUseCase.kt index adc56089..3ad5f513 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/AuthenticationUseCase.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/AuthenticationUseCase.kt @@ -18,55 +18,97 @@ package de.gematik.ti.erp.app.userauthentication.ui -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod +import androidx.compose.runtime.Stable +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.settings.model.SettingsData.AuthenticationMode.Password import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.distinctUntilChanged -import javax.inject.Inject -import javax.inject.Singleton +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import java.time.Duration +@Stable sealed class AuthenticationModeAndMethod { object None : AuthenticationModeAndMethod() object Authenticated : AuthenticationModeAndMethod() - data class AuthenticationRequired(val method: SettingsAuthenticationMethod, val nrOfFailedAuthentications: Int) : + data class AuthenticationRequired(val method: SettingsData.AuthenticationMode, val nrOfFailedAuthentications: Int) : AuthenticationModeAndMethod() } -@Singleton -class AuthenticationUseCase @Inject constructor( - private val settingsUseCase: SettingsUseCase -) : LifecycleObserver { +private val InactivityTimeout = Duration.ofMinutes(10) +private val PauseTimeout = Duration.ofSeconds(10) +private const val ResetTimeout = -1L + +// tag::AuthenticationUseCase[] +class AuthenticationUseCase( + private val settingsUseCase: SettingsUseCase, + dispatchers: DispatchProvider +) : LifecycleEventObserver { private enum class Lifecycle { - Started, Running, Stopped + Created, Started, Running, Paused } + private val scope = CoroutineScope(dispatchers.Default) + private val authRequired = MutableStateFlow(false) - private val lifecycle = MutableStateFlow(Lifecycle.Started) + private val lifecycle = MutableStateFlow(Lifecycle.Created) + private val timerChannel = Channel(Channel.CONFLATED) + + private var unspecifiedAuthentication = false + + private fun authenticationFlow() = + combineTransform( + lifecycle, + authRequired, + settingsUseCase.authenticationMode, + settingsUseCase.general + ) { lifecycle, authRequired, authenticationMode, settings -> + unspecifiedAuthentication = authenticationMode is SettingsData.AuthenticationMode.Unspecified - val authenticationModeAndMethod = - combineTransform(lifecycle, authRequired, settingsUseCase.settings) { lifecycle, authRequired, settings -> - @Suppress("deprecation") when (lifecycle) { - Lifecycle.Started -> { - this@AuthenticationUseCase.authRequired.value = when (settings.authenticationMethod) { - SettingsAuthenticationMethod.None, - SettingsAuthenticationMethod.Unspecified -> false + Lifecycle.Created -> { + this@AuthenticationUseCase.authRequired.value = when (authenticationMode) { + SettingsData.AuthenticationMode.None, + SettingsData.AuthenticationMode.Unspecified -> false else -> true } this@AuthenticationUseCase.lifecycle.value = Lifecycle.Running } + Lifecycle.Started -> { + this@AuthenticationUseCase.lifecycle.value = Lifecycle.Running + } Lifecycle.Running -> { - when (settings.authenticationMethod) { - SettingsAuthenticationMethod.None, - SettingsAuthenticationMethod.Unspecified -> + when (authenticationMode) { + SettingsData.AuthenticationMode.None, + SettingsData.AuthenticationMode.Unspecified -> emit(AuthenticationModeAndMethod.Authenticated) else -> if (authRequired) { emit( AuthenticationModeAndMethod.AuthenticationRequired( - settings.authenticationMethod, + authenticationMode, settings.authenticationFails ) ) @@ -75,34 +117,96 @@ class AuthenticationUseCase @Inject constructor( } } } - Lifecycle.Stopped -> emit(AuthenticationModeAndMethod.None) + Lifecycle.Paused -> emit(AuthenticationModeAndMethod.None) } }.distinctUntilChanged() + val authenticationModeAndMethod: Flow = + channelFlow { + launch { + var currentTimeout: Long = ResetTimeout + timerChannel + .receiveAsFlow() + .filter { timeout -> + currentTimeout <= 0 || timeout <= currentTimeout + } + .collectLatest { timeout -> + currentTimeout = timeout + if (timeout > 0) { + Napier.d { "Restarted inactivity timer for ${Duration.ofMillis(timeout)}" } + delay(timeout) + requireAuthentication() + currentTimeout = ResetTimeout + } else { + timerChannel.send(InactivityTimeout.toMillis()) + } + } + } + + Napier.d { "Started authentication flow" } + + authenticationFlow() + .collect { + if (it == AuthenticationModeAndMethod.Authenticated) { + timerChannel.send(InactivityTimeout.toMillis()) + } + + Napier.d { "Current authentication mode $it" } + + send(it) + } + }.flowOn(dispatchers.Default) + .shareIn(scope = scope, started = SharingStarted.Lazily, replay = 1) + // end::AuthenticationUseCase[] + suspend fun isPasswordValid(password: String): Boolean = - settingsUseCase.isPasswordValid(password) + settingsUseCase.authenticationMode.map { + (it as? Password)?.isValid(password) ?: false + }.first() - fun requireAuthentication() { - authRequired.value = true + fun resetInactivityTimer() { + timerChannel.trySendBlocking(InactivityTimeout.toMillis()) } fun authenticated() { authRequired.value = false } + private fun requireAuthentication() { + if (!unspecifiedAuthentication) { + authRequired.value = true + Napier.d { "Authentication required" } + } + } + + private fun requireAuthentication(inMillis: Long) { + val result = timerChannel.trySendBlocking(inMillis) + if (result.isFailure) { + requireAuthentication() + } + } + suspend fun incrementNumberOfAuthenticationFailures() = settingsUseCase.incrementNumberOfAuthenticationFailures() suspend fun resetNumberOfAuthenticationFailures() = settingsUseCase.resetNumberOfAuthenticationFailures() - @OnLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_START) - fun onStartApp() { - lifecycle.value = Lifecycle.Started - } - - @OnLifecycleEvent(androidx.lifecycle.Lifecycle.Event.ON_STOP) - fun onStopApp() { - lifecycle.value = Lifecycle.Stopped + override fun onStateChanged(source: LifecycleOwner, event: Event) { + Napier.d { "Authentication lifecycle event state: $event" } + when (event) { + ON_CREATE -> lifecycle.value = Lifecycle.Created + ON_START -> { + if (lifecycle.value != Lifecycle.Created) { + lifecycle.value = Lifecycle.Started + } + timerChannel.trySendBlocking(ResetTimeout) + } + ON_STOP -> { + lifecycle.value = Lifecycle.Paused + requireAuthentication(PauseTimeout.toMillis()) + } + else -> {} + } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/BiometricPrompt.kt b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/BiometricPrompt.kt index 77ab706f..febc3c9a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/BiometricPrompt.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/BiometricPrompt.kt @@ -27,12 +27,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import de.gematik.ti.erp.app.core.LocalActivity -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod +import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.utils.compose.createToastShort +// tag::BiometricPromptAndBestSecureOption[] + @Composable fun BiometricPrompt( - authenticationMethod: SettingsAuthenticationMethod, + authenticationMethod: SettingsData.AuthenticationMode, title: String, description: String, negativeButton: String, @@ -85,7 +87,7 @@ fun BiometricPrompt( val promptInfo = remember { val secureOption = bestSecureOption(biometricManager) - if (authenticationMethod == SettingsAuthenticationMethod.DeviceCredentials) { + if (authenticationMethod == SettingsData.AuthenticationMode.DeviceCredentials) { BiometricPrompt.PromptInfo.Builder() .setTitle(title) .setDescription(description) @@ -93,7 +95,7 @@ fun BiometricPrompt( BiometricManager.Authenticators.DEVICE_CREDENTIAL ) .build() - } else if (authenticationMethod == SettingsAuthenticationMethod.Biometrics) { + } else if (authenticationMethod == SettingsData.AuthenticationMode.Biometrics) { BiometricPrompt.PromptInfo.Builder() .setTitle(title) .setDescription(description) @@ -144,9 +146,11 @@ private fun bestSecureOption(biometricManager: BiometricManager): Int { BiometricManager.BIOMETRIC_SUCCESS, BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> return BiometricManager.Authenticators.DEVICE_CREDENTIAL } - return if (android.os.Build.VERSION.SDK_INT < 30) { + return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK } else { BiometricManager.Authenticators.DEVICE_CREDENTIAL } } + +// end::BiometricPromptAndBestSecureOption[] diff --git a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt index dedc8743..682c5fee 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationScreenComponents.kt @@ -21,8 +21,11 @@ package de.gematik.ti.erp.app.userauthentication.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -31,16 +34,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -50,27 +52,22 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues -import com.google.accompanist.insets.statusBarsPadding -import com.google.accompanist.insets.systemBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod +import de.gematik.ti.erp.app.settings.model.SettingsData import de.gematik.ti.erp.app.settings.ui.PasswordTextField import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults @@ -80,6 +77,7 @@ import de.gematik.ti.erp.app.utils.compose.HintCard import de.gematik.ti.erp.app.utils.compose.HintCardDefaults import de.gematik.ti.erp.app.utils.compose.HintSmallImage import de.gematik.ti.erp.app.utils.compose.OutlinedDebugButton +import de.gematik.ti.erp.app.utils.compose.PrimaryButton import de.gematik.ti.erp.app.utils.compose.SpacerLarge import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall @@ -87,22 +85,15 @@ import de.gematik.ti.erp.app.utils.compose.SpacerTiny import de.gematik.ti.erp.app.utils.compose.annotatedLinkString import de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource import de.gematik.ti.erp.app.utils.compose.annotatedStringResource -import de.gematik.ti.erp.app.utils.compose.handleIntent -import de.gematik.ti.erp.app.utils.compose.providePhoneIntent -import de.gematik.ti.erp.app.utils.compose.testId -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel import java.util.Locale - +@Suppress("LongMethod") @Composable -fun UserAuthenticationScreen(userAuthViewModel: UserAuthenticationViewModel = hiltViewModel()) { - val flag = painterResource(R.drawable.ic_onboarding_logo_flag) - val gematik = painterResource(R.drawable.ic_onboarding_logo_gematik) - val context = LocalContext.current - +fun UserAuthenticationScreen() { + val userAuthViewModel: UserAuthenticationViewModel by rememberViewModel() var showAuthPrompt by remember { mutableStateOf(false) } var showError by remember { mutableStateOf(false) } - var initiallyHandledAuthPrompt by rememberSaveable { mutableStateOf(false) } val state by produceState(userAuthViewModel.defaultState) { userAuthViewModel.screenState().collect { @@ -113,149 +104,67 @@ fun UserAuthenticationScreen(userAuthViewModel: UserAuthenticationViewModel = hi initiallyHandledAuthPrompt = true } } - - val navBarInsetsPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.systemBars, - applyBottom = true - ) - - val paddingModifier = if (navBarInsetsPadding.calculateBottomPadding() <= 16.dp) { + val navBarInsetsPadding = WindowInsets.systemBars.asPaddingValues() + val paddingModifier = if (navBarInsetsPadding.calculateBottomPadding() <= PaddingDefaults.Medium) { Modifier.statusBarsPadding() } else { Modifier.systemBarsPadding() } + // clear underlying text input focus + val focusManager = LocalFocusManager.current + LaunchedEffect(Unit) { + focusManager.clearFocus(true) + } - Scaffold(paddingModifier) { innerPadding -> + Scaffold { Column( modifier = Modifier + .padding(it) .fillMaxSize() - .testId("auth_screen") - .padding(innerPadding) .verticalScroll(rememberScrollState()) + .then(paddingModifier) ) { Row( modifier = Modifier - .padding(start = 24.dp, top = 40.dp) + .padding(top = PaddingDefaults.Medium) + .padding(horizontal = PaddingDefaults.Medium) .align(Alignment.Start), verticalAlignment = Alignment.CenterVertically ) { - Image(flag, null, modifier = Modifier.padding(end = 10.dp)) - Icon(gematik, null, tint = AppTheme.colors.primary900) + Image( + painterResource(R.drawable.ic_onboarding_logo_flag), + null, + modifier = Modifier.padding(end = 10.dp) + ) + Icon( + painterResource(R.drawable.ic_onboarding_logo_gematik), + null, + tint = AppTheme.colors.primary900 + ) } - Column( - modifier = Modifier - .padding(horizontal = PaddingDefaults.Large) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!showError && state.nrOfAuthFailures > 0) { - HintCard( - modifier = Modifier.padding( - top = PaddingDefaults.Large - ), - properties = HintCardDefaults.flatProperties( - backgroundColor = AppTheme.colors.red100 - ), - image = { - HintSmallImage( - painterResource(R.drawable.oh_no_girl_hint_red), - innerPadding = it - ) - }, - title = { Text(stringResource(R.string.auth_error_failed_auths_headline)) }, - body = { - Text( - annotatedPluralsResource( - R.plurals.auth_error_failed_auths_info, - state.nrOfAuthFailures, - AnnotatedString(state.nrOfAuthFailures.toString()) - ) - ) - } - ) - Spacer(modifier = Modifier.height(40.dp)) - } - if (!showError) { - Text( - stringResource(R.string.auth_headline), - style = MaterialTheme.typography.h5.copy(fontWeight = FontWeight(700)), - modifier = Modifier.padding(top = 80.dp) - ) - SpacerMedium() - } else { - Image( - painterResource(R.drawable.woman_red_shirt_circle_red), - null, - modifier = Modifier.padding(top = 40.dp, start = 56.dp, end = 56.dp) - ) - } - Text( - stringResource(if (showError) R.string.auth_subtitle_error else R.string.auth_subtitle), - style = MaterialTheme.typography.subtitle1 + if (showError) { + AuthenticationScreenErrorContent( + showAuthPromptOnClick = { showAuthPrompt = true } ) - SpacerTiny() - Text( - stringResource(if (showError) R.string.auth_info_error else R.string.auth_info), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.subtitle1, - color = AppTheme.typographyColors.subtitle1l + } else { + AuthenticationScreenContent( + showAuthPromptOnClick = { showAuthPrompt = true }, + state = state ) - SpacerLarge() - Button( - onClick = { - showAuthPrompt = true - }, - elevation = ButtonDefaults.elevation(8.dp), - shape = RoundedCornerShape(8.dp) - ) { - Icon(Icons.Rounded.LockOpen, null) - SpacerTiny() - Text(stringResource(R.string.auth_button)) - } } - Spacer(modifier = Modifier.weight(1f)) - if (showError) { - Column( - modifier = Modifier - .background(color = AppTheme.colors.neutral100) - .padding(24.dp) - .fillMaxWidth() - ) { - val uriHandler = LocalUriHandler.current - val phoneContact = stringResource(R.string.auth_hotlinephone_contact) - val color = AppTheme.colors.primary600 - - val link = annotatedLinkString( - stringResource(R.string.auth_link_to_gematik), - stringResource(R.string.auth_link_to_gematik_text) - ) - val annotatedPhoneText = - providePhoneString(phoneContact, phoneContact, "PHONE", linkColor = color) - - ClickableTaggedText( - annotatedStringResource(R.string.auth_more_hotline, annotatedPhoneText), - style = AppTheme.typography.subtitle2l.merge(TextStyle(textAlign = TextAlign.Center)), - onClick = { - context.handleIntent(providePhoneIntent(phoneContact)) - } - ) - SpacerSmall() - ClickableTaggedText( - annotatedStringResource(R.string.auth_more_web, link), - style = AppTheme.typography.subtitle2l.merge(TextStyle(textAlign = TextAlign.Center)), - onClick = { range -> - uriHandler.openUri(range.item) - } - ) - } + AuthenticationScreenErrorBottomContent( + state = state + ) } else { Image( painterResource(R.drawable.crew), null, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium), contentScale = ContentScale.FillWidth ) } @@ -264,7 +173,7 @@ fun UserAuthenticationScreen(userAuthViewModel: UserAuthenticationViewModel = hi if (showAuthPrompt) { when (state.authenticationMethod) { - SettingsAuthenticationMethod.Password -> + is SettingsData.AuthenticationMode.Password -> PasswordPrompt( userAuthViewModel, onAuthenticated = { @@ -306,6 +215,163 @@ fun UserAuthenticationScreen(userAuthViewModel: UserAuthenticationViewModel = hi } } +@Composable +private fun AuthenticationScreenErrorContent( + showAuthPromptOnClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(80.dp)) + Image( + painterResource(R.drawable.woman_red_shirt_circle_red), + null, + alignment = Alignment.Center + ) + SpacerMedium() + Text( + stringResource(R.string.auth_subtitle_error), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + SpacerSmall() + Text( + stringResource(R.string.auth_info_error), + textAlign = TextAlign.Center, + style = AppTheme.typography.body1l + ) + SpacerLarge() + PrimaryButton( + onClick = showAuthPromptOnClick, + elevation = ButtonDefaults.elevation(8.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues( + horizontal = PaddingDefaults.Large, + vertical = PaddingDefaults.ShortMedium + ) + ) { + Icon(Icons.Rounded.LockOpen, null) + SpacerTiny() + SpacerSmall() + Text(stringResource(R.string.auth_button)) + } + } +} + +@Composable +private fun AuthenticationScreenErrorBottomContent(state: UserAuthenticationScreenState) { + Column( + modifier = Modifier + .background(color = AppTheme.colors.neutral100) + .padding( + bottom = PaddingDefaults.Large, + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium, + top = PaddingDefaults.Medium + ) + .fillMaxWidth() + ) { + val uriHandler = LocalUriHandler.current + val link = annotatedLinkString( + stringResource(R.string.auth_link_to_gematik_q_and_a), + stringResource(R.string.auth_link_to_gematik_helptext) + ) + when (state.authenticationMethod) { + SettingsData.AuthenticationMode.DeviceSecurity -> + Text( + text = stringResource(R.string.auth_failed_biometry_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + else -> + ClickableTaggedText( + annotatedStringResource(R.string.auth_failed_password_info, link), + style = AppTheme.typography.body2l.merge(TextStyle(textAlign = TextAlign.Center)), + onClick = { range -> + uriHandler.openUri(range.item) + } + ) + } + } +} + +@Composable +private fun AuthenticationScreenContent( + showAuthPromptOnClick: () -> Unit, + state: UserAuthenticationScreenState +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.nrOfAuthFailures > 0) { + HintCard( + modifier = Modifier.padding(vertical = PaddingDefaults.Medium), + properties = HintCardDefaults.flatProperties( + backgroundColor = AppTheme.colors.red100 + ), + image = { + HintSmallImage( + painterResource(R.drawable.oh_no_girl_hint_red), + innerPadding = it + ) + }, + title = { Text(stringResource(R.string.auth_error_failed_auths_headline)) }, + body = { + Text( + annotatedPluralsResource( + R.plurals.auth_error_failed_auths_info, + state.nrOfAuthFailures, + AnnotatedString(state.nrOfAuthFailures.toString()) + ) + ) + } + ) + } else { + Spacer(modifier = Modifier.height(80.dp)) + } + + Text( + stringResource(R.string.auth_headline), + style = AppTheme.typography.h5, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + SpacerMedium() + Text( + stringResource(R.string.auth_subtitle), + style = AppTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + SpacerSmall() + Text( + stringResource(R.string.auth_information), + textAlign = TextAlign.Center, + style = AppTheme.typography.body1l + ) + SpacerLarge() + PrimaryButton( + onClick = showAuthPromptOnClick, + elevation = ButtonDefaults.elevation(8.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues( + horizontal = PaddingDefaults.Large, + vertical = PaddingDefaults.ShortMedium + ) + ) { + Icon(Icons.Rounded.LockOpen, null) + SpacerTiny() + SpacerSmall() + Text(stringResource(R.string.auth_button)) + } + } +} + @Composable private fun PasswordPrompt( viewModel: UserAuthenticationViewModel, @@ -337,8 +403,7 @@ private fun PasswordPrompt( onAuthenticationError() } } - }, - modifier = Modifier.testId("auth/forward") + } ) { Text(stringResource(R.string.auth_prompt_check_password).uppercase(Locale.getDefault())) } @@ -347,7 +412,6 @@ private fun PasswordPrompt( text = { PasswordTextField( modifier = Modifier - .testId("auth/passwordInput") .fillMaxWidth() .heightIn(min = 56.dp), value = password, @@ -364,28 +428,3 @@ private fun PasswordPrompt( } ) } - -fun providePhoneString( - text: String, - annotation: String = text, - tag: String, - start: Int = 0, - end: Int = text.length, - linkColor: Color -) = - buildAnnotatedString { - append(text) - addStyle( - style = SpanStyle( - color = linkColor, - fontWeight = FontWeight.Bold - ), - start = start, end = end - ) - addStringAnnotation( - tag = tag, - annotation = annotation, - start = start, - end = end - ) - } diff --git a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt index fa5ac194..912542fc 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/userauthentication/ui/UserAuthenticationViewModel.kt @@ -19,24 +19,21 @@ package de.gematik.ti.erp.app.userauthentication.ui import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import de.gematik.ti.erp.app.core.BaseViewModel -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.settings.model.SettingsData import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject data class UserAuthenticationScreenState( - val authenticationMethod: SettingsAuthenticationMethod, + val authenticationMethod: SettingsData.AuthenticationMode, val nrOfAuthFailures: Int ) -@HiltViewModel -class UserAuthenticationViewModel @Inject constructor( +class UserAuthenticationViewModel( private val authUseCase: AuthenticationUseCase -) : BaseViewModel() { +) : ViewModel() { var defaultState = UserAuthenticationScreenState( - authenticationMethod = SettingsAuthenticationMethod.Unspecified, + authenticationMethod = SettingsData.AuthenticationMode.Unspecified, nrOfAuthFailures = 0 ) @@ -45,7 +42,7 @@ class UserAuthenticationViewModel @Inject constructor( when (it) { AuthenticationModeAndMethod.None, AuthenticationModeAndMethod.Authenticated -> UserAuthenticationScreenState( - SettingsAuthenticationMethod.Unspecified, + SettingsData.AuthenticationMode.Unspecified, 0 ) is AuthenticationModeAndMethod.AuthenticationRequired -> UserAuthenticationScreenState( diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/DateTime.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/DateTime.kt index 2c828ba8..d472098d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/DateTime.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/DateTime.kt @@ -19,14 +19,51 @@ package de.gematik.ti.erp.app.utils import java.time.Instant +import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.Year +import java.time.YearMonth +import java.time.ZoneId import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.time.temporal.TemporalAccessor val dateTimeShortFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) -fun dateTimeShortText(instant: Instant): String = LocalDateTime.ofEpochSecond( - instant.epochSecond, 0, - ZoneOffset.UTC -).atOffset(ZoneOffset.UTC).format(dateTimeShortFormatter) +fun dateTimeShortText(instant: Instant): String = + LocalDateTime + .ofInstant(instant, ZoneId.systemDefault()) + .format(dateTimeShortFormatter) + +val dateTimeMediumFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + +fun dateTimeMediumText(instant: Instant, zoneId: ZoneId = ZoneOffset.UTC): String = + LocalDateTime + .ofInstant(instant, zoneId) + .format(dateTimeMediumFormatter) + +private val YearMonthPattern = DateTimeFormatter.ofPattern("MMMM yyyy") +private val MonthPattern = DateTimeFormatter.ofPattern("yyyy") + +fun temporalText(temporalAccessor: TemporalAccessor, zoneId: ZoneId = ZoneOffset.UTC): String = + when (temporalAccessor) { + is Instant -> + LocalDateTime + .ofInstant(temporalAccessor, zoneId) + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) + is LocalDate -> + temporalAccessor + .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) + is YearMonth -> + temporalAccessor + .format(YearMonthPattern) + is Year -> + temporalAccessor + .format(MonthPattern) + is LocalTime -> + temporalAccessor + .format(DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM)) + else -> "n.a." + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/TextUtil.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/TextUtil.kt index 408f82bf..7cbb6d51 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/TextUtil.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/TextUtil.kt @@ -19,50 +19,6 @@ package de.gematik.ti.erp.app.utils import kotlin.streams.asSequence -import kotlin.streams.toList - -fun firstCharOfForeNameSurName(name: String): String { - val names = name.split(" ", "-").filter { - it.isNotBlank() - } - return when { - names.size > 1 -> { - val letterFirst = names.first().codePointAt(0) - val letterLast = names.last().codePointAt(0) - if (letterFirst.isEmoticon()) { - joinCombinedEmoji(names.first()) - } else { - (Character.toChars(letterFirst) + Character.toChars(letterLast)).concatToString().uppercase() - } - } - names.size == 1 -> { - val letter = names.first().codePointAt(0) - if (letter.isEmoticon()) { - joinCombinedEmoji(names.first()) - } else { - Character.toChars(letter).concatToString().uppercase() - } - } - else -> "" - } -} - -// Combined emojis use https://en.wikipedia.org/wiki/Zero-width_joiner -private fun joinCombinedEmoji(value: String): String { - var lastLetter = 0 - var outString = "" - for (letter in value.codePoints().toList()) { - outString += when { - (lastLetter == 0 || lastLetter == 0x200d) && letter.isEmoticon() -> - Character.toChars(letter).concatToString() - letter == 0x200d -> - Character.toChars(letter).concatToString() - else -> break - } - lastLetter = letter - } - return outString -} // see https://unicode.org/emoji/charts/full-emoji-list.html private fun Int.isEmoticon() = this in 0x1F600..0xE007F @@ -81,3 +37,4 @@ fun sanitizeProfileName(name: String): String = } } .joinToString("") + .replaceFirstChar { it.uppercase() } diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/Utils.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/Utils.kt deleted file mode 100644 index 399b7d56..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/Utils.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.utils - -import android.content.Intent -import android.net.Uri -import android.webkit.WebView -import android.webkit.WebViewClient -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.temporal.ChronoUnit -import java.util.Date - -fun Date.convertFhirDateToOffsetDateTime(offset: ZoneOffset = ZoneOffset.UTC): OffsetDateTime { - val offsetDateTime = this.toInstant().atOffset(offset) - return offsetDateTime.truncatedTo(ChronoUnit.SECONDS) -} - -fun Date.convertFhirDateToLocalDate(): LocalDate { - return this.toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDate() -} -fun Date.convertFhirDateToLocalDateTime(): LocalDateTime { - return this.toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDateTime() -} - -fun createWebViewClient() = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - return when { - url.startsWith("https://") -> { - view.context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - true - } - url.startsWith("#") -> { - view.loadUrl(url) - true - } - else -> { - false - } - } - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/AnimatedElevationScaffold.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/AnimatedElevationScaffold.kt index 038210f5..497ed62f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/AnimatedElevationScaffold.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/AnimatedElevationScaffold.kt @@ -19,11 +19,17 @@ package de.gematik.ti.erp.app.utils.compose import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.AppBarDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -33,6 +39,7 @@ import androidx.compose.ui.unit.dp @Composable fun AnimatedElevationScaffold( modifier: Modifier = Modifier, + scaffoldState: ScaffoldState = rememberScaffoldState(), topBarColor: Color = MaterialTheme.colors.surface, navigationMode: NavigationBarMode = NavigationBarMode.Close, bottomBar: @Composable () -> Unit = {}, @@ -41,14 +48,16 @@ fun AnimatedElevationScaffold( onBack: () -> Unit, content: @Composable (PaddingValues) -> Unit ) { + val elevated by derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } Scaffold( modifier = modifier, + scaffoldState = scaffoldState, topBar = { NavigationTopAppBar( navigationMode = navigationMode, backgroundColor = topBarColor, title = topBarTitle, - elevation = if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + elevation = if (elevated) { AppBarDefaults.TopAppBarElevation } else { 0.dp @@ -64,12 +73,51 @@ fun AnimatedElevationScaffold( @Composable fun AnimatedElevationScaffold( modifier: Modifier = Modifier, + scaffoldState: ScaffoldState = rememberScaffoldState(), topBarColor: Color = MaterialTheme.colors.surface, - navigationMode: NavigationBarMode = NavigationBarMode.Close, + navigationMode: NavigationBarMode? = NavigationBarMode.Close, + bottomBar: @Composable () -> Unit = {}, + topBarTitle: String, + listState: LazyListState, + onBack: () -> Unit, + snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + actions: @Composable RowScope.() -> Unit, + content: @Composable (PaddingValues) -> Unit +) { + val elevated by derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } + Scaffold( + modifier = modifier, + scaffoldState = scaffoldState, + topBar = { + NavigationTopAppBar( + navigationMode = navigationMode, + backgroundColor = topBarColor, + title = topBarTitle, + elevation = if (elevated) { + AppBarDefaults.TopAppBarElevation + } else { + 0.dp + }, + onBack = onBack, + actions = actions + ) + }, + snackbarHost = snackbarHost, + bottomBar = bottomBar, + content = content + ) +} + +@Composable +fun AnimatedElevationScaffold( + modifier: Modifier = Modifier, + topBarColor: Color = MaterialTheme.colors.surface, + navigationMode: NavigationBarMode? = NavigationBarMode.Close, bottomBar: @Composable () -> Unit = {}, topBarTitle: String, elevated: Boolean, onBack: () -> Unit, + actions: @Composable (RowScope.() -> Unit), content: @Composable (PaddingValues) -> Unit ) { val elevation = remember(elevated) { if (elevated) AppBarDefaults.TopAppBarElevation else 0.dp } @@ -82,10 +130,46 @@ fun AnimatedElevationScaffold( backgroundColor = topBarColor, title = topBarTitle, elevation = elevation, + onBack = onBack, + actions = actions + ) + }, + bottomBar = bottomBar, + content = content + ) +} + +@Composable +fun AnimatedElevationScaffold( + modifier: Modifier = Modifier, + scaffoldState: ScaffoldState = rememberScaffoldState(), + topBarColor: Color = MaterialTheme.colors.surface, + navigationMode: NavigationBarMode = NavigationBarMode.Close, + bottomBar: @Composable () -> Unit = {}, + topBarTitle: String, + listState: LazyListState, + onBack: () -> Unit, + snackbarHost: @Composable (SnackbarHostState) -> Unit, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + modifier = modifier, + scaffoldState = scaffoldState, + topBar = { + NavigationTopAppBar( + navigationMode = navigationMode, + backgroundColor = topBarColor, + title = topBarTitle, + elevation = if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + AppBarDefaults.TopAppBarElevation + } else { + 0.dp + }, onBack = onBack ) }, bottomBar = bottomBar, + snackbarHost = snackbarHost, content = content ) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Animations.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Animations.kt index 55dc6752..b3676fdc 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Animations.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Animations.kt @@ -49,7 +49,7 @@ enum class NavigationMode { fun NavigationAnimation( modifier: Modifier = Modifier, mode: NavigationMode = NavigationMode.Forward, - content: @Composable() (AnimatedVisibilityScope.() -> Unit) + content: @Composable AnimatedVisibilityScope.() -> Unit ) { val transition = when (mode) { NavigationMode.Forward -> slideInHorizontally(initialOffsetX = { it / 2 }) @@ -66,7 +66,6 @@ fun NavigationAnimation( ) } -@OptIn(ExperimentalAnimationApi::class) @Composable fun NavHostController.navigationModeState( startDestination: String, diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/BottomSheetAction.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/BottomSheetAction.kt index 97d90d87..c1323b94 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/BottomSheetAction.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/BottomSheetAction.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.Icon import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -46,22 +45,23 @@ fun BottomSheetAction( icon: ImageVector, title: String, info: String, - onClick: () -> Unit, + onClick: () -> Unit ) = BottomSheetAction( modifier = modifier, enabled = enabled, icon = { Icon( - icon, null, + icon, + null, modifier = Modifier .size(24.dp) - .align(Alignment.CenterVertically), + .align(Alignment.CenterVertically) ) }, title = { Text(title) }, info = { Text(info) }, - onClick = onClick, + onClick = onClick ) @Composable @@ -71,7 +71,7 @@ fun BottomSheetAction( icon: @Composable RowScope.() -> Unit, title: @Composable ColumnScope.() -> Unit, info: @Composable ColumnScope.() -> Unit, - onClick: () -> Unit, + onClick: () -> Unit ) { val titleColor = if (enabled) { Color.Unspecified @@ -98,13 +98,13 @@ fun BottomSheetAction( SpacerMedium() Column { CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.subtitle1, + LocalTextStyle provides AppTheme.typography.subtitle1, LocalContentColor provides if (titleColor == Color.Unspecified) LocalContentColor.current else titleColor ) { title() } CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.body2, + LocalTextStyle provides AppTheme.typography.body2, LocalContentColor provides textColor ) { info() diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Buttons.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Buttons.kt new file mode 100644 index 00000000..ff57b176 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Buttons.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.utils.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ButtonElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults + +// TODO replace with material3 + +@Composable +fun SecondaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors( + backgroundColor = AppTheme.colors.neutral100, + contentColor = AppTheme.colors.primary700 + ), + contentPadding: PaddingValues = PaddingValues( + horizontal = PaddingDefaults.Medium, + vertical = 13.dp + ), + content: @Composable RowScope.() -> Unit +) = + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = contentPadding, + content = content + ) + +@Composable +fun TertiaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke? = BorderStroke(width = 1.dp, color = AppTheme.colors.neutral300), + colors: ButtonColors = ButtonDefaults.buttonColors( + backgroundColor = AppTheme.colors.neutral050, + contentColor = AppTheme.colors.primary600 + ), + contentPadding: PaddingValues = PaddingValues( + horizontal = PaddingDefaults.Large, + vertical = PaddingDefaults.Small + ), + content: @Composable RowScope.() -> Unit +) = + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = contentPadding, + content = content + ) + +@Composable +fun PrimaryButtonLarge( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + content: @Composable RowScope.() -> Unit +) = PrimaryButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = PaddingValues( + horizontal = 64.dp, + vertical = 13.dp + ), + content = content +) + +@Composable +fun PrimaryButtonSmall( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + content: @Composable RowScope.() -> Unit +) = PrimaryButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = PaddingValues( + horizontal = 48.dp, + vertical = 13.dp + ), + content = content +) + +@Composable +fun PrimaryButtonTiny( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + content: @Composable RowScope.() -> Unit +) = PrimaryButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 4.dp + ), + content = content +) + +@Composable +fun PrimaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + contentPadding: PaddingValues = PaddingValues( + horizontal = PaddingDefaults.Medium, + vertical = 7.dp + ), + content: @Composable RowScope.() -> Unit +) = + Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = contentPadding, + content = content + ) diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Chip.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Chip.kt index c5bed25a..f020ed48 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Chip.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Chip.kt @@ -21,17 +21,14 @@ package de.gematik.ti.erp.app.utils.compose import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -42,6 +39,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults @Composable fun Chip( @@ -51,11 +49,11 @@ fun Chip( closable: Boolean, onCheckedChange: (Boolean) -> Unit ) { - val color = if (checked) AppTheme.colors.primary600 else AppTheme.colors.neutral200 - val textColor = if (checked && !closable) AppTheme.colors.neutral000 else AppTheme.colors.neutral999 + val textColor = if (checked) AppTheme.colors.neutral000 else AppTheme.colors.neutral600 + val backgroundColor = if (checked) AppTheme.colors.primary600 else AppTheme.colors.neutral100 Row( modifier = modifier - .clip(CircleShape) + .clip(RoundedCornerShape(8.dp)) .toggleable( checked, role = Role.Checkbox, @@ -63,23 +61,19 @@ fun Chip( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple() ) - .background(color = color, shape = CircleShape) - .padding(vertical = 6.dp), + .background(color = backgroundColor, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = PaddingDefaults.ShortMedium, vertical = PaddingDefaults.ShortMedium / 2), verticalAlignment = Alignment.CenterVertically ) { - Spacer(modifier = Modifier.width(12.dp)) - Text(text, style = MaterialTheme.typography.caption, color = textColor) + Text(text, style = AppTheme.typography.subtitle2, color = textColor) if (closable && !checked) { SpacerSmall() Icon( - Icons.Rounded.Cancel, + Icons.Rounded.Close, null, - tint = AppTheme.colors.neutral400, + tint = AppTheme.colors.neutral600, modifier = Modifier.size(16.dp) ) - SpacerSmall() - } else { - Spacer(modifier = Modifier.width(12.dp)) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt index 2638517a..d540f150 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Common.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -54,17 +55,15 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonElevation -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LocalAbsoluteElevation import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Switch -import androidx.compose.material.SwitchColors -import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextFieldColors @@ -90,6 +89,7 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -115,17 +115,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag import de.gematik.ti.erp.app.core.LocalActivity import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import de.gematik.ti.erp.app.utils.sanitizeProfileName -import timber.log.Timber +import io.github.aakira.napier.Napier +import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Date -import java.util.Locale @Composable fun SpacerMaxWidth() = @@ -175,6 +175,14 @@ fun SpacerXXLarge() = fun SpacerMedium() = Spacer(modifier = Modifier.size(PaddingDefaults.Medium)) +@Composable +fun SpacerShortMedium() = + Spacer(modifier = Modifier.size(PaddingDefaults.ShortMedium)) + +@Composable +fun SpacerXXLargeMedium() = + Spacer(modifier = Modifier.size(PaddingDefaults.XXLargeMedium)) + @Composable fun SpacerSmall() = Spacer(modifier = Modifier.size(PaddingDefaults.Small)) @@ -251,11 +259,12 @@ fun NavigationClose(modifier: Modifier = Modifier, onClick: () -> Unit) { IconButton( onClick = onClick, modifier = modifier - .testId("nav_btn_back") .semantics { contentDescription = acc } + .testTag(TestTag.TopNavigation.CloseButton) ) { Icon( - Icons.Rounded.Close, null, + Icons.Rounded.Close, + null, tint = MaterialTheme.colors.primary, modifier = Modifier.size(24.dp) ) @@ -266,10 +275,12 @@ fun NavigationClose(modifier: Modifier = Modifier, onClick: () -> Unit) { fun annotatedLinkString(uri: String, text: String, tag: String = "URL"): AnnotatedString = buildAnnotatedString { pushStringAnnotation(tag, uri) - pushStyle(SpanStyle(color = AppTheme.colors.primary600, fontWeight = FontWeight.Bold)) + pushStyle(AppTheme.typography.subtitle2.toSpanStyle()) + pushStyle(SpanStyle(color = AppTheme.colors.primary600)) append(text) pop() pop() + pop() } @Composable @@ -318,10 +329,13 @@ fun NavigationBack(modifier: Modifier = Modifier, onClick: () -> Unit) { IconButton( onClick = onClick, - modifier = modifier.semantics { contentDescription = acc } + modifier = modifier + .semantics { contentDescription = acc } + .testTag(TestTag.TopNavigation.BackButton) ) { Icon( - Icons.Rounded.ArrowBack, null, + Icons.Rounded.ArrowBack, + null, tint = MaterialTheme.colors.primary, modifier = Modifier.size(24.dp) ) @@ -335,26 +349,28 @@ enum class NavigationBarMode { @Composable fun NavigationTopAppBar( - navigationMode: NavigationBarMode, + navigationMode: NavigationBarMode?, title: String, backgroundColor: Color = MaterialTheme.colors.surface, elevation: Dp = AppBarDefaults.TopAppBarElevation, + actions: @Composable RowScope.() -> Unit = {}, onBack: () -> Unit ) = TopAppBar( title = { - Text(title) + Text(title, overflow = TextOverflow.Ellipsis) }, backgroundColor = backgroundColor, navigationIcon = { when (navigationMode) { NavigationBarMode.Back -> NavigationBack { onBack() } NavigationBarMode.Close -> NavigationClose { onBack() } + else -> {} } }, - elevation = elevation + elevation = elevation, + actions = actions ) -@OptIn(ExperimentalMaterialApi::class) @Composable fun LabeledSwitch( checked: Boolean, @@ -363,7 +379,7 @@ fun LabeledSwitch( enabled: Boolean = true, icon: ImageVector, header: String, - description: String + description: String? ) { LabeledSwitch( checked = checked, @@ -371,10 +387,15 @@ fun LabeledSwitch( modifier = modifier, enabled = enabled ) { + val iconColorTint = if (enabled) AppTheme.colors.primary600 else AppTheme.colors.primary300 + val textColor = if (enabled) AppTheme.colors.neutral900 else AppTheme.colors.neutral600 + val descriptionColor = if (enabled) AppTheme.colors.neutral600 else AppTheme.colors.neutral400 + Row( modifier = Modifier.weight(1.0f) ) { - Icon(icon, null, tint = AppTheme.colors.primary500) + Icon(icon, null, tint = iconColorTint) + SpacerSmall() Column( modifier = Modifier .weight(1.0f) @@ -382,25 +403,27 @@ fun LabeledSwitch( ) { Text( text = header, - style = MaterialTheme.typography.body1, - ) - Text( - text = description, - style = AppTheme.typography.body2l + style = AppTheme.typography.body1, + color = textColor ) + if (description != null) { + Text( + text = description, + style = AppTheme.typography.body2l, + color = descriptionColor + ) + } } } } } -@OptIn(ExperimentalMaterialApi::class) @Composable fun LabeledSwitch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - colors: SwitchColors = SwitchDefaults.colors(), label: @Composable RowScope.() -> Unit ) { Row( @@ -419,15 +442,16 @@ fun LabeledSwitch( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - label() - Switch( - checked = checked, - onCheckedChange = null, - enabled = enabled, - colors = colors - ) + // for better visibility in dark mode + CompositionLocalProvider(LocalAbsoluteElevation provides 8.dp) { + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled + ) + } } } @@ -439,22 +463,7 @@ fun annotatedStringResource(@StringRes id: Int, vararg args: Any): AnnotatedStri fun annotatedStringResource(@StringRes id: Int, vararg args: AnnotatedString): AnnotatedString = buildAnnotatedString { val res = stringResource(id) - val argIt = args.iterator() - var i = 0 - while (i <= res.length) { - val j = res.indexOf("%s", i) - - if (j != -1) { - append(res.substring(i, j)) - append(argIt.next()) - - i = j + 2 - } else { - append(res.substring(i, res.length)) - - break - } - } + appendSubStrings(args, res) } @Composable @@ -477,23 +486,31 @@ fun annotatedPluralsResource( ): AnnotatedString = buildAnnotatedString { val res = resources().getQuantityString(id, quantity) - val argIt = args.iterator() - var i = 0 - while (i <= res.length) { - val j = res.indexOf("%s", i) - if (j != -1) { - append(res.substring(i, j)) - append(argIt.next()) + appendSubStrings(args, res) + } - i = j + 2 - } else { - append(res.substring(i, res.length)) +private fun AnnotatedString.Builder.appendSubStrings( + args: Array, + res: String +) { + val argIt = args.iterator() + var i = 0 + while (i <= res.length) { + val j = res.indexOf("%s", i) - break - } + if (j != -1) { + append(res.substring(i, j)) + append(argIt.next()) + + i = j + 2 + } else { + append(res.substring(i, res.length)) + + break } } +} @Composable fun annotatedStringBold(text: String) = @@ -505,48 +522,81 @@ fun annotatedStringBold(text: String) = @Composable fun CommonAlertDialog( + icon: ImageVector? = null, header: String?, info: String, cancelText: String, actionText: String, + enabled: Boolean = true, onCancel: () -> Unit, - onClickAction: () -> Unit, + onClickAction: () -> Unit ) = AlertDialog( + modifier = Modifier.testTag(TestTag.AlertDialog.Modal), title = header?.let { { Text(header) } }, onDismissRequest = onCancel, text = { Text(info) }, + icon = icon, buttons = { - TextButton(onClick = onCancel) { - Text(cancelText.uppercase(Locale.getDefault())) + TextButton( + modifier = Modifier.testTag(TestTag.AlertDialog.CancelButton), + onClick = onCancel, + enabled = enabled + ) { + Text(cancelText) } - TextButton(onClick = onClickAction) { - Text(actionText.uppercase(Locale.getDefault())) + TextButton( + modifier = Modifier.testTag(TestTag.AlertDialog.ConfirmButton), + onClick = onClickAction, + enabled = enabled + ) { + Text(actionText) } } ) @Composable fun CommonAlertDialog( + icon: ImageVector? = null, header: AnnotatedString?, info: AnnotatedString, cancelText: String, actionText: String, onCancel: () -> Unit, - onClickAction: () -> Unit, + onClickAction: () -> Unit ) = AlertDialog( + icon = icon, title = header?.let { { Text(header) } }, onDismissRequest = onCancel, text = { Text(info) }, buttons = { TextButton(onClick = onCancel) { - Text(cancelText.uppercase(Locale.getDefault())) + Text(cancelText) } TextButton(onClick = onClickAction) { - Text(actionText.uppercase(Locale.getDefault())) + Text(actionText) + } + } + ) + +@Composable +fun AcceptDialog( + header: AnnotatedString, + info: AnnotatedString, + acceptText: String, + onClickAccept: () -> Unit +) = + AlertDialog( + title = { Text(header) }, + onDismissRequest = {}, + text = { Text(info) }, + buttons = { + TextButton(onClick = onClickAccept) { + Text(acceptText) } }, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) ) @Composable @@ -554,7 +604,7 @@ fun AcceptDialog( header: String, info: String, acceptText: String, - onClickAccept: () -> Unit, + onClickAccept: () -> Unit ) = AlertDialog( title = { Text(header) }, @@ -562,7 +612,7 @@ fun AcceptDialog( text = { Text(info) }, buttons = { TextButton(onClick = onClickAccept) { - Text(acceptText.uppercase(Locale.getDefault())) + Text(acceptText) } }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) @@ -599,7 +649,7 @@ fun Context.handleIntent( try { startActivity(intent) } catch (e: ActivityNotFoundException) { - Timber.e(e) + Napier.e("Couldn't start intent", e) onCouldNotHandleIntent?.let { it() } } } @@ -627,7 +677,7 @@ fun DynamicText( onTextLayout: (TextLayoutResult) -> Unit = {}, style: TextStyle = LocalTextStyle.current ) { - CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.subtitle1) { + CompositionLocalProvider(LocalTextStyle provides AppTheme.typography.subtitle1) { SubcomposeLayout(modifier = Modifier.wrapContentSize()) { constraints -> val contentPlaceables = inlineContent.mapValues { (key, content) -> val maxSize = subcompose(key, content = { content.children(key) }).map { @@ -685,58 +735,83 @@ fun DynamicText( @Composable fun SimpleCheck(text: String) { - Row { - Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green600) - Spacer16() - Text(text, style = MaterialTheme.typography.body1) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green500) + SpacerMedium() + Text(text, style = AppTheme.typography.body1, modifier = Modifier.weight(1f)) } } @OptIn(ExperimentalComposeUiApi::class) @Composable -fun ProfileNameInputField( +fun InputField( modifier: Modifier, value: String, onValueChange: (String) -> Unit, - onSubmit: (profileName: String) -> Unit, + onSubmit: (value: String) -> Unit, label: @Composable (() -> Unit)? = null, colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors(), - isError: Boolean = false + isError: Boolean = false, + errorText: @Composable (() -> Unit)? = null, + keyBoardType: KeyboardType? = null ) { - val initialProfileName = rememberSaveable { value } - - OutlinedTextField( - value = value, - onValueChange = { - onValueChange(sanitizeProfileName(it)) - }, - modifier = modifier - .heightIn(min = 56.dp), - singleLine = true, - keyboardOptions = KeyboardOptions( - autoCorrect = true, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions { - if (value.isNotEmpty()) { - onSubmit(value) + val initialValue = rememberSaveable { value } + val undoDescription = stringResource(R.string.onb_undo_description) + Column { + OutlinedTextField( + value = value, + onValueChange = { + onValueChange(it) + }, + modifier = modifier + .heightIn(min = 56.dp), + singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrect = true, + keyboardType = keyBoardType ?: KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions { + if (value.isNotEmpty()) { + onSubmit(value) + } + }, + label = label, + shape = RoundedCornerShape(8.dp), + colors = colors, + isError = isError, + trailingIcon = if (initialValue != value) { + { + IconButton( + modifier = Modifier + .semantics { contentDescription = undoDescription }, + onClick = { onValueChange(initialValue) } + ) { + Icon(Icons.Rounded.Undo, null) + } + } + } else { + null } - }, - label = label, - shape = RoundedCornerShape(8.dp), - colors = colors, - isError = isError, - trailingIcon = if (initialProfileName != value) { - { - IconButton(onClick = { onValueChange(initialProfileName) }) { - Icon(Icons.Rounded.Undo, null) + ) + if (isError) { + errorText?.let { + CompositionLocalProvider( + LocalTextStyle provides AppTheme.typography.caption1, + LocalContentColor provides AppTheme.colors.red600 + ) { + Box(Modifier.padding(start = PaddingDefaults.Medium, top = PaddingDefaults.Small)) { + errorText() + } } } - } else { - null } - ) + } } @Composable @@ -755,3 +830,48 @@ fun phrasedDateString(date: LocalDateTime): String { return "${date.format(dateFormatter)} $at ${timeFormatter.format(timeOfDate)}" } + +fun dateString(date: LocalDateTime): String { + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + return date.format(dateFormatter) +} + +fun timeString(date: LocalDateTime): String { + val timeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + return date.format(timeFormatter) +} + +/** + * Combines the two args to something like "created at Jan 12, 1952" + */ +@Composable +fun dateWithIntroductionString(@StringRes id: Int, instant: Instant): String { + val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } + val date = remember { + instant.atZone(ZoneId.systemDefault()) + .toLocalDate().format(dateFormatter) + } + val combinedString = annotatedStringResource(id, date).toString() + return remember { combinedString } +} + +/** + * Shows the given content if != null labeled with a description as described in Figma for ProfileScreen. + */ +@Composable +fun LabeledText(description: String, content: String?) { + if (content != null) { + Text(content, style = AppTheme.typography.body1) + Text(description, style = AppTheme.typography.body2l) + SpacerMedium() + } +} + +/** + * Same as [LabeledText] but uses the given resource for the description tag. + * + */ +@Composable +fun LabeledText(descriptionResource: Int, content: String?) { + LabeledText(stringResource(descriptionResource), content) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Dialog.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Dialog.kt index 341d8307..74f5f911 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Dialog.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Dialog.kt @@ -22,6 +22,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -29,12 +30,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -44,34 +47,36 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.key import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import com.google.accompanist.flowlayout.MainAxisAlignment import com.google.accompanist.flowlayout.FlowRow -import com.google.accompanist.insets.imePadding +import com.google.accompanist.flowlayout.MainAxisAlignment +import androidx.compose.foundation.layout.systemBarsPadding +import de.gematik.ti.erp.app.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import java.util.UUID @Composable fun AlertDialog( onDismissRequest: () -> Unit, - buttons: @Composable () -> Unit, modifier: Modifier = Modifier, + icon: ImageVector? = null, + buttons: @Composable () -> Unit, title: (@Composable () -> Unit)? = null, text: (@Composable () -> Unit)? = null, shape: Shape = RoundedCornerShape(PaddingDefaults.Large), @@ -96,11 +101,13 @@ fun AlertDialog( Box( Modifier .semantics(false) { } + .imePadding() .fillMaxSize() .then(dismissModifier) .background(SolidColor(Color.Black), alpha = 0.5f) + .systemBarsPadding() .verticalScroll(rememberScrollState()) - .imePadding(), + .padding(vertical = PaddingDefaults.Medium), contentAlignment = Alignment.Center ) { Surface( @@ -108,13 +115,18 @@ fun AlertDialog( .wrapContentHeight() .fillMaxWidth(0.78f), color = backgroundColor, + border = BorderStroke(1.dp, AppTheme.colors.neutral300), contentColor = contentColor, shape = shape, elevation = 8.dp ) { Column(Modifier.padding(PaddingDefaults.Large)) { + icon?.let { + Icon(icon, null, modifier = Modifier.align(Alignment.CenterHorizontally)) + SpacerMedium() + } CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.h6 + LocalTextStyle provides AppTheme.typography.h6 ) { title?.let { title() @@ -122,7 +134,7 @@ fun AlertDialog( } } CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.body2 + LocalTextStyle provides AppTheme.typography.body2 ) { text?.let { text() @@ -170,8 +182,10 @@ fun DialogHost( content: @Composable () -> Unit ) { Box(Modifier.fillMaxSize()) { + val dialogHostState = remember { DialogHostState() } + CompositionLocalProvider( - LocalDialogHostState provides DialogHostState() + LocalDialogHostState provides dialogHostState ) { content() diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Hints.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Hints.kt index f38d0140..2dfc22c6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Hints.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/Hints.kt @@ -40,7 +40,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredWidth @@ -84,6 +83,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.theme.AppTheme +import de.gematik.ti.erp.app.theme.PaddingDefaults import kotlinx.coroutines.delay import java.util.Locale @@ -93,17 +93,17 @@ data class HintCardProperties( val backgroundColor: Color, val contentColor: Color?, val border: BorderStroke?, - val elevation: Dp, + val elevation: Dp ) object HintCardDefaults { @Composable fun properties( shape: Shape = RoundedCornerShape(8.dp), - backgroundColor: Color = MaterialTheme.colors.surface, + backgroundColor: Color = AppTheme.colors.neutral050, contentColor: Color? = null, - border: BorderStroke = BorderStroke(0.5.dp, AppTheme.colors.neutral300), - elevation: Dp = 2.dp + border: BorderStroke = BorderStroke(1.dp, AppTheme.colors.neutral300), + elevation: Dp = 0.dp ) = HintCardProperties( shape = shape, backgroundColor = backgroundColor, @@ -147,7 +147,7 @@ fun HintCard( backgroundColor = properties.backgroundColor, contentColor = properties.contentColor ?: contentColorFor(properties.backgroundColor), border = properties.border, - elevation = properties.elevation, + elevation = properties.elevation ) { if (properties.contentColor != null) { MaterialTheme( @@ -170,7 +170,7 @@ private fun HintCardInnerLayout( action: (@Composable ColumnScope.() -> Unit)? = null, close: (@Composable (innerPadding: PaddingValues) -> Unit)? = null ) { - val padding = 16.dp + val padding = PaddingDefaults.Medium val innerPaddingLeft = PaddingValues(start = padding, top = padding, bottom = padding) val innerPaddingRight = PaddingValues(end = padding, top = padding, bottom = padding) @@ -180,7 +180,6 @@ private fun HintCardInnerLayout( clip = false } ) { - image(innerPaddingLeft) Column( @@ -205,7 +204,7 @@ private fun HintCardInnerLayout( .padding(top = padding, end = padding) ) { CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.subtitle1 + LocalTextStyle provides AppTheme.typography.subtitle1 ) { title() } @@ -214,7 +213,7 @@ private fun HintCardInnerLayout( close(innerPaddingRight) } } - Spacer4() + SpacerTiny() } Column( modifier = Modifier @@ -225,12 +224,12 @@ private fun HintCardInnerLayout( } ) { CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.body2 + LocalTextStyle provides AppTheme.typography.body2 ) { body() } if (action != null) { - Spacer4() + SpacerTiny() action() } } @@ -336,7 +335,7 @@ fun HintActionButton( onClick: () -> Unit ) { Button( - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.padding(top = PaddingDefaults.Tiny), onClick = onClick, elevation = ButtonDefaults.elevation( defaultElevation = 8.dp, @@ -370,7 +369,7 @@ fun HintTextActionButton( enabled = enabled, shape = RoundedCornerShape(8.dp) ) { - Text(text) + Text(text = text, style = AppTheme.typography.body2) } } @@ -387,9 +386,10 @@ fun HintTextLearnMoreButton( onClick = { uriHandler.openUri(uri) }, enabled = true, align = align, - text = stringResource(R.string.learn_more_btn) + text = stringResource(R.string.cdw_health_insurance_caption_recognize_healthcard) ) } + @Suppress("UNUSED_PARAMETER") @Composable fun HintCloseButton( @@ -545,7 +545,8 @@ fun ClosableHintCardWithActionButtonAndColors() { ), image = { Icon( - Icons.Rounded.WarningAmber, null, + Icons.Rounded.WarningAmber, + null, modifier = Modifier .padding(it) .requiredSize(40.dp) @@ -571,7 +572,8 @@ fun ClosableHintCardWithActionButtonAndColorsAndShortTitle() { ), image = { Icon( - Icons.Rounded.WarningAmber, null, + Icons.Rounded.WarningAmber, + null, modifier = Modifier .padding(it) .requiredSize(40.dp) @@ -596,7 +598,8 @@ fun HintCardWithNoTitle() { ), image = { Icon( - Icons.Rounded.WarningAmber, null, + Icons.Rounded.WarningAmber, + null, modifier = Modifier .padding(it) .requiredSize(40.dp) @@ -619,7 +622,8 @@ fun ClosableHintCardWithNoTitle() { ), image = { Icon( - Icons.Rounded.WarningAmber, null, + Icons.Rounded.WarningAmber, + null, modifier = Modifier .padding(it) .requiredSize(40.dp) @@ -651,14 +655,15 @@ fun AnimatedHintCardPreview() { ), image = { Icon( - Icons.Rounded.WarningAmber, null, + Icons.Rounded.WarningAmber, + null, modifier = Modifier .padding(it) .requiredSize(40.dp) ) }, title = null, - body = { Text("Hier tippen, um sie in einer Apotheke einzulösen, Hier tippen, um sie in einer Apotheke einzulösen, Hier tippen, um sie in einer Apotheke einzulösen") }, + body = { Text("Hier tippen, um sie in einer Apotheke einzulösen, Hier tippen, um sie in einer Apotheke einzulösen, Hier tippen, um sie in einer Apotheke einzulösen") } ) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/InsetAwareBars.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/InsetAwareBars.kt index 9ebc10cf..37da0606 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/InsetAwareBars.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/InsetAwareBars.kt @@ -18,6 +18,7 @@ package de.gematik.ti.erp.app.utils.compose +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.material.AppBarDefaults @@ -34,11 +35,14 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.rememberInsetsPaddingValues -import com.google.accompanist.insets.statusBarsPadding -import com.google.accompanist.insets.systemBarsPadding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding @Composable fun TopAppBar( @@ -68,6 +72,38 @@ fun TopAppBar( } } +@Composable +fun TopAppBarWithContent( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + backgroundColor: Color = MaterialTheme.colors.primarySurface, + contentColor: Color = contentColorFor(backgroundColor), + elevation: Dp = AppBarDefaults.TopAppBarElevation, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier, + color = backgroundColor, + elevation = elevation, + shape = RectangleShape + ) { + Column { + androidx.compose.material.TopAppBar( + title, + Modifier.statusBarsPadding(), + navigationIcon, + actions, + backgroundColor, + contentColor, + elevation = 0.dp + ) + content() + } + } +} + @Composable fun BottomAppBar( modifier: Modifier = Modifier, @@ -101,6 +137,7 @@ fun BottomNavigation( backgroundColor: Color = MaterialTheme.colors.primarySurface, contentColor: Color = contentColorFor(backgroundColor), elevation: Dp = BottomNavigationDefaults.Elevation, + extraContent: @Composable () -> Unit, content: @Composable RowScope.() -> Unit ) { Surface( @@ -108,21 +145,22 @@ fun BottomNavigation( color = backgroundColor, elevation = elevation ) { - androidx.compose.material.BottomNavigation( - Modifier.navigationBarsPadding(), - backgroundColor, - contentColor, - elevation = 0.dp, - content - ) + Column { + extraContent() + + androidx.compose.material.BottomNavigation( + Modifier.navigationBarsPadding(), + backgroundColor, + contentColor, + elevation = 0.dp, + content + ) + } } } -fun Modifier.minimalSystemBarsPadding() = Modifier.composed { - val navBarInsetsPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.systemBars, - applyBottom = true - ) +fun Modifier.minimalSystemBarsPadding() = composed { + val navBarInsetsPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues() if (navBarInsetsPadding.calculateBottomPadding() <= 16.dp) { statusBarsPadding() diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/compose/TimeDescription.kt b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/TimeDescription.kt new file mode 100644 index 00000000..cff11bd0 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/utils/compose/TimeDescription.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.utils.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import de.gematik.ti.erp.app.R +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +enum class TimeDiff { + FewMinutes, + Today, + Other +} + +private const val FewMinutes = 5L // 5 minutes +private const val Today = 1440L / 2L // 12 hours + +private fun timeDiff(diffMinutes: Long): TimeDiff = + when { + diffMinutes < FewMinutes -> TimeDiff.FewMinutes + diffMinutes < Today -> TimeDiff.Today + else -> TimeDiff.Other + } + +typealias TimeDescriptionFormatter = (diff: TimeDiff, localDt: LocalDateTime, duration: Duration) -> String + +@Composable +fun timeDescription( + instant: Instant, + formatter: TimeDescriptionFormatter = TimeDescriptionDefaults.formatter() +): State { + LocalConfiguration.current + + val dt by rememberUpdatedState(instant) + val fmt by rememberUpdatedState(formatter) + val timeString = remember(dt, fmt) { + val duration = Duration.between(dt, Instant.now()) + val diffMinutes = duration.toMinutes() + val localDt = LocalDateTime.ofInstant(dt, ZoneId.systemDefault()) + mutableStateOf(fmt(timeDiff(diffMinutes = diffMinutes), localDt, duration)) + } + return timeString +} + +object TimeDescriptionDefaults { + + @Composable + fun formatter(): TimeDescriptionFormatter { + val fewMinutes = stringResource(R.string.time_description_few_minutes) + val today = stringResource(R.string.time_description_today) + + return remember { + val hours = DateTimeFormatter.ofPattern("HH:mm") + val other = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + + val fmt: TimeDescriptionFormatter = { diff, localDt, _ -> + when (diff) { + TimeDiff.FewMinutes -> fewMinutes + TimeDiff.Today -> today.format(hours.format(localDt)) + TimeDiff.Other -> other.format(localDt) + } + } + fmt + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/VauModule.kt b/android/src/main/java/de/gematik/ti/erp/app/vau/VauModule.kt new file mode 100644 index 00000000..82de7b27 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/vau/VauModule.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.vau + +import de.gematik.ti.erp.app.di.EndpointHelper +import de.gematik.ti.erp.app.di.NetworkSecurePreferencesTag +import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList +import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList +import de.gematik.ti.erp.app.vau.interceptor.DefaultCryptoConfig +import de.gematik.ti.erp.app.vau.interceptor.VauChannelInterceptor +import de.gematik.ti.erp.app.vau.repository.VauLocalDataSource +import de.gematik.ti.erp.app.vau.repository.VauRemoteDataSource +import de.gematik.ti.erp.app.vau.repository.VauRepository +import de.gematik.ti.erp.app.vau.usecase.TrustedTruststore +import de.gematik.ti.erp.app.vau.usecase.TrustedTruststoreProvider +import de.gematik.ti.erp.app.vau.usecase.TruststoreConfig +import de.gematik.ti.erp.app.vau.usecase.TruststoreTimeSourceProvider +import de.gematik.ti.erp.app.vau.usecase.TruststoreUseCase +import java.time.Duration +import java.time.Instant +import org.bouncycastle.cert.X509CertificateHolder +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.instance + +val vauModule = DI.Module("vauModule") { + bindSingleton { + val endpointHelper = instance() + TruststoreConfig(endpointHelper::getTrustAnchor) + } + bindSingleton { VauRemoteDataSource(instance()) } + bindSingleton { VauLocalDataSource(instance()) } + bindSingleton { VauRepository(instance(), instance(), instance()) } + bindSingleton { DefaultCryptoConfig() } + bindSingleton { + VauChannelInterceptor( + endpointHelper = instance(), + truststore = instance(), + cryptoConfig = instance(), + dispatchers = instance(), + networkSecPrefs = instance(NetworkSecurePreferencesTag) + ) + } + bindSingleton { { Instant.now() } } + bindSingleton { + { untrustedOCSPList: UntrustedOCSPList, + untrustedCertList: UntrustedCertList, + trustAnchor: X509CertificateHolder, + ocspResponseMaxAge: Duration, + timestamp: Instant -> + TrustedTruststore.create( + untrustedOCSPList = untrustedOCSPList, + untrustedCertList = untrustedCertList, + trustAnchor = trustAnchor, + ocspResponseMaxAge = ocspResponseMaxAge, + timestamp = timestamp + ) + } + } + bindSingleton { TruststoreUseCase(instance(), instance(), instance(), instance()) } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/api/model/MoshiAdapters.kt b/android/src/main/java/de/gematik/ti/erp/app/vau/api/model/MoshiAdapters.kt deleted file mode 100644 index ead70379..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/api/model/MoshiAdapters.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau.api.model - -import com.squareup.moshi.FromJson -import com.squareup.moshi.JsonWriter -import com.squareup.moshi.ToJson -import org.bouncycastle.cert.X509CertificateHolder -import org.bouncycastle.cert.ocsp.OCSPResp -import org.bouncycastle.util.encoders.Base64 - -class OCSPAdapter { - @FromJson - fun fromJson(ocspRespAsBase64: String): OCSPResp { - val bytes = Base64.decode(ocspRespAsBase64) - return OCSPResp(bytes) - } - - @ToJson - fun toJson(writer: JsonWriter, ocspResp: OCSPResp) { - writer.jsonValue(Base64.toBase64String(ocspResp.encoded!!)) - } -} - -class X509Adapter { - @FromJson - fun fromJson(x509AsBase64: String): X509CertificateHolder { - val x509Bytes = Base64.decode(x509AsBase64) - return X509CertificateHolder(x509Bytes) - } - - @ToJson - fun toJson(writer: JsonWriter, cert: X509CertificateHolder) { - writer.jsonValue(Base64.toBase64String(cert.encoded!!)) - } -} - -class X509ArrayAdapter { - @FromJson - fun fromJson(x509AsBase64: Array): X509CertificateHolder { - val x509Bytes = Base64.decode(x509AsBase64[0]) - return X509CertificateHolder(x509Bytes) - } - - @ToJson - fun toJson(writer: JsonWriter, cert: X509CertificateHolder) { - writer.jsonValue(Base64.toBase64String(cert.encoded!!)) - } -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/api/model/VauModels.kt b/android/src/main/java/de/gematik/ti/erp/app/vau/api/model/VauModels.kt deleted file mode 100644 index 14fe47ed..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/api/model/VauModels.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau.api.model - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.bouncycastle.cert.X509CertificateHolder -import org.bouncycastle.cert.ocsp.OCSPResp - -/** - * Reflects a json array with the following structure: - * - * { - * "add_roots" : [ "base64-kodiertes-Root-Cross-Zertifikat-1", ... ], - * "ca_certs" : [ "base64-kodiertes-Komponenten-CA-Zertifikat-1", ... ], - * "ee_certs" : [ "base64-kodiertes-EE-Zertifikat-1-aus-einer-Komponenten-CA", ... ] - * } - * - * Refer to gemSpec_Krypt `Tab_KRYPT_ERP_Zertifikatsliste` - */ -@JsonClass(generateAdapter = true) -data class UntrustedCertList( - // additional cross roots - @Json(name = "add_roots") - val addRoots: List, - - // ca certs - @Json(name = "ca_certs") - val caCerts: List, - - // vau & idp certs - @Json(name = "ee_certs") - val eeCerts: List -) - -/** - * OCSP list: - * - * { - * "OCSP Responses": [ "base64 encoded ocsp response", ... ] - * } - * - */ -@JsonClass(generateAdapter = true) -data class UntrustedOCSPList( - @Json(name = "OCSP Responses") - val responses: List -) diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt b/android/src/main/java/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt index 30b61302..5995d89a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt @@ -24,7 +24,6 @@ import de.gematik.ti.erp.app.BCProvider import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.DispatchProvider import de.gematik.ti.erp.app.di.EndpointHelper -import de.gematik.ti.erp.app.di.NetworkSecureSharedPreferences import de.gematik.ti.erp.app.secureRandomInstance import de.gematik.ti.erp.app.vau.VauChannelSpec import de.gematik.ti.erp.app.vau.VauCryptoConfig @@ -33,15 +32,14 @@ import kotlinx.coroutines.runBlocking import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.Response -import timber.log.Timber +import io.github.aakira.napier.Napier import java.io.IOException import java.net.HttpURLConnection.HTTP_FORBIDDEN import java.net.HttpURLConnection.HTTP_UNAUTHORIZED import java.security.Provider import java.security.SecureRandom -import javax.inject.Inject -class DefaultCryptoConfig @Inject constructor() : VauCryptoConfig { +class DefaultCryptoConfig : VauCryptoConfig { override val provider: Provider by lazy { BCProvider } override val random: SecureRandom get() = secureRandomInstance() @@ -54,12 +52,12 @@ private const val VAU_USER_ALIAS_PREF_KEY = "VAU_USER_ALIAS" */ class VauException(e: Exception) : IOException(e) -class VauChannelInterceptor @Inject constructor( +class VauChannelInterceptor( endpointHelper: EndpointHelper, private val truststore: TruststoreUseCase, private val cryptoConfig: VauCryptoConfig, - private val dispatchProvider: DispatchProvider, - @NetworkSecureSharedPreferences private val networkSecPrefs: SharedPreferences + private val dispatchers: DispatchProvider, + private val networkSecPrefs: SharedPreferences ) : Interceptor { // `gemSpec_Krypt A_20175` private var previousUserAlias = networkSecPrefs.getString(VAU_USER_ALIAS_PREF_KEY, null) ?: "0" @@ -73,12 +71,12 @@ class VauChannelInterceptor @Inject constructor( override fun intercept(chain: Interceptor.Chain): Response { if (BuildKonfig.INTERNAL && !BuildKonfig.VAU_ENABLE_INTERCEPTOR) { - Timber.d("VAU interceptor disabled - pass requests") + Napier.d("VAU interceptor disabled - pass requests") return chain.proceed(chain.request()) } try { - val encryptedRequest = runBlocking(dispatchProvider.io()) { + val encryptedRequest = runBlocking(dispatchers.IO) { truststore.withValidVauPublicKey { publicKey -> VauChannelSpec.V1.encryptHttpRequest( chain.request(), diff --git a/android/src/main/java/de/gematik/ti/erp/app/webview/WebViewScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/webview/WebViewScreen.kt index 63055a8a..6824e0a1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/webview/WebViewScreen.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/webview/WebViewScreen.kt @@ -18,9 +18,15 @@ package de.gematik.ti.erp.app.webview +import android.content.Intent +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView +import android.webkit.WebViewClient import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Scaffold +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Typography import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateOf @@ -28,49 +34,61 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.updateLayoutParams +import androidx.webkit.WebViewAssetLoader +import de.gematik.ti.erp.app.utils.compose.AnimatedElevationScaffold import de.gematik.ti.erp.app.utils.compose.NavigationBarMode -import de.gematik.ti.erp.app.utils.compose.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.createWebViewClient const val URI_TERMS_OF_USE = "file:///android_asset/terms_of_use.html" const val URI_DATA_TERMS = "file:///android_asset/data_terms.html" -const val URI_LICENCES = "file:///android_asset/open_source_licenses.html" @Composable fun WebViewScreen( + modifier: Modifier = Modifier, title: String, url: String, navigationMode: NavigationBarMode = NavigationBarMode.Back, onBack: () -> Unit ) { - Scaffold( - topBar = { - NavigationTopAppBar( - navigationMode = navigationMode, - title = title, - onBack = onBack - ) - } + var scrollState by remember { mutableStateOf(0) } + AnimatedElevationScaffold( + elevated = scrollState > 0, + navigationMode = navigationMode, + bottomBar = {}, + actions = {}, + topBarTitle = title, + onBack = onBack, + modifier = modifier ) { - WebView(Modifier.fillMaxSize(), url) + WebView(Modifier.fillMaxSize(), url, onScroll = { scrollState = it }) } } @Composable private fun WebView( modifier: Modifier, - url: String + url: String, + onScroll: (y: Int) -> Unit ) { val context = LocalContext.current - val webView = remember { + val colors = MaterialTheme.colors + val typo = MaterialTheme.typography + val webView = remember(colors, typo) { WebView(context).apply { + setBackgroundColor(colors.background.toArgb()) settings.javaScriptEnabled = false - webViewClient = createWebViewClient() + setOnScrollChangeListener { _, _, scrollY, _, _ -> onScroll(scrollY) } + webViewClient = createWebViewClient(colors, typo) } } @@ -99,3 +117,87 @@ private fun WebView( } } } + +private fun TextUnit.toCSS(): String { + val unit = when (this.type) { + TextUnitType.Sp -> "sp" + TextUnitType.Em -> "em" + else -> "px" + } + return "${this.value}$unit" +} + +private const val MaxColorIntValue = 255 + +private fun Float.toIntColor() = (this * MaxColorIntValue).toInt() + +private fun Color.toCSS(): String = + "rgba(${this.red.toIntColor()}, ${this.green.toIntColor()}, ${this.blue.toIntColor()}, ${this.alpha})" + +private fun typoColor(tag: String, style: TextStyle): String = + """ + |$tag { + | color: inherit; + | font-size: ${style.fontSize.toCSS()}; + | font-weight: ${style.fontWeight?.weight ?: FontWeight.Medium.weight}; + | line-height: ${style.lineHeight.toCSS()}; + | letter-spacing: ${style.letterSpacing.toCSS()}; + |} + """.trimMargin() + +fun createWebViewClient(colors: Colors, typo: Typography) = object : WebViewClient() { + private val css = """ + |body { + | color: ${colors.onBackground.toCSS()}; + | background: ${colors.background.toCSS()}; + | padding: 16px; + | word-wrap: break-word; + |} + |li { + | padding-bottom: 4px; + |} + |h1, h2, h3, h4 { + | padding-top: 0.5em; + | margin: 0; + |} + |${typoColor("h1", typo.h1)} + |${typoColor("h2", typo.h2)} + |${typoColor("h3", typo.h3)} + |${typoColor("h4", typo.h4)} + |${typoColor("p", typo.body1)} + |table, th, td { + | border-collapse: collapse; + | border: 0.1px solid ${colors.onSurface.toCSS()}; + |} + |th, td { + | padding: 0.5em; + |} + |a, a:link, a:visited { + | color: ${colors.primary.toCSS()}; + | text-decoration: none; + |} + """.trimMargin() + + private val cssLoader = WebViewAssetLoader.Builder() + .setDomain("localhost") + .addPathHandler("/style/") { + WebResourceResponse("text/css", "UTF-8", css.byteInputStream()) + } + .build() + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + return cssLoader.shouldInterceptRequest(request.url) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return if (request.url.scheme == "https" && request.url.host != "localhost") { + view.context.startActivity(Intent(Intent.ACTION_VIEW, request.url)) + true + } else { + false + } + } +} diff --git a/android/src/main/res/drawable-xhdpi/.webp b/android/src/main/res/drawable-xhdpi/.webp new file mode 100644 index 00000000..a090b0c3 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/.webp differ diff --git a/android/src/main/res/drawable-xhdpi/alarm_clock.webp b/android/src/main/res/drawable-xhdpi/alarm_clock.webp new file mode 100644 index 00000000..de3a4c1d Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/alarm_clock.webp differ diff --git a/android/src/main/res/drawable-xhdpi/baby_portrait.webp b/android/src/main/res/drawable-xhdpi/baby_portrait.webp new file mode 100644 index 00000000..c502a852 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/baby_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/baby_small_portrait.webp b/android/src/main/res/drawable-xhdpi/baby_small_portrait.webp new file mode 100644 index 00000000..245b2d71 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/baby_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/boy_green_shirt_card_circle.webp b/android/src/main/res/drawable-xhdpi/boy_green_shirt_card_circle.webp deleted file mode 100644 index 755eb790..00000000 Binary files a/android/src/main/res/drawable-xhdpi/boy_green_shirt_card_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/boy_with_health_card_portrait.webp b/android/src/main/res/drawable-xhdpi/boy_with_health_card_portrait.webp new file mode 100644 index 00000000..a101489e Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/boy_with_health_card_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/boy_with_health_card_small_portrait.webp b/android/src/main/res/drawable-xhdpi/boy_with_health_card_small_portrait.webp new file mode 100644 index 00000000..f121422b Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/boy_with_health_card_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/calling_lady.webp b/android/src/main/res/drawable-xhdpi/calling_lady.webp deleted file mode 100644 index 60aab5b5..00000000 Binary files a/android/src/main/res/drawable-xhdpi/calling_lady.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/card_wall_card_can.webp b/android/src/main/res/drawable-xhdpi/card_wall_card_can.webp index 8a1fd283..e3bed0c1 100644 Binary files a/android/src/main/res/drawable-xhdpi/card_wall_card_can.webp and b/android/src/main/res/drawable-xhdpi/card_wall_card_can.webp differ diff --git a/android/src/main/res/drawable-xhdpi/card_wall_man.webp b/android/src/main/res/drawable-xhdpi/card_wall_man.webp deleted file mode 100644 index 8055e8b9..00000000 Binary files a/android/src/main/res/drawable-xhdpi/card_wall_man.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/clapping_hands.webp b/android/src/main/res/drawable-xhdpi/clapping_hands.webp deleted file mode 100644 index d7a19a85..00000000 Binary files a/android/src/main/res/drawable-xhdpi/clapping_hands.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/clapping_hands_blue.webp b/android/src/main/res/drawable-xhdpi/clapping_hands_blue.webp new file mode 100644 index 00000000..fcf48988 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/clapping_hands_blue.webp differ diff --git a/android/src/main/res/drawable-xhdpi/clapping_hands_hint_yellow.webp b/android/src/main/res/drawable-xhdpi/clapping_hands_hint_yellow.webp deleted file mode 100644 index 3d2d5567..00000000 Binary files a/android/src/main/res/drawable-xhdpi/clapping_hands_hint_yellow.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/connect_profile.webp b/android/src/main/res/drawable-xhdpi/connect_profile.webp deleted file mode 100644 index 1beb9585..00000000 Binary files a/android/src/main/res/drawable-xhdpi/connect_profile.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/developer.webp b/android/src/main/res/drawable-xhdpi/developer.webp new file mode 100644 index 00000000..9c8c26fd Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/developer.webp differ diff --git a/android/src/main/res/drawable-xhdpi/doctor_circle.webp b/android/src/main/res/drawable-xhdpi/doctor_circle.webp deleted file mode 100644 index f2d58fbc..00000000 Binary files a/android/src/main/res/drawable-xhdpi/doctor_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/doctor_with_phone_portrait.webp b/android/src/main/res/drawable-xhdpi/doctor_with_phone_portrait.webp new file mode 100644 index 00000000..54b7102a Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/doctor_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/doctor_with_phone_small_portrait.webp b/android/src/main/res/drawable-xhdpi/doctor_with_phone_small_portrait.webp new file mode 100644 index 00000000..7cde68b1 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/doctor_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/femal_developer_portrait.webp b/android/src/main/res/drawable-xhdpi/femal_developer_portrait.webp new file mode 100644 index 00000000..51611e73 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/femal_developer_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/femal_developer_small_portrait.webp b/android/src/main/res/drawable-xhdpi/femal_developer_small_portrait.webp new file mode 100644 index 00000000..8f46387f Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/femal_developer_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/femal_doctor_portrait.webp b/android/src/main/res/drawable-xhdpi/femal_doctor_portrait.webp new file mode 100644 index 00000000..b2db0816 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/femal_doctor_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/femal_doctor_small_portrait.webp b/android/src/main/res/drawable-xhdpi/femal_doctor_small_portrait.webp new file mode 100644 index 00000000..a090b0c3 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/femal_doctor_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/femal_doctor_with_phone_portrait.webp b/android/src/main/res/drawable-xhdpi/femal_doctor_with_phone_portrait.webp new file mode 100644 index 00000000..5c3ba19f Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/femal_doctor_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/femal_doctor_with_phone_small_portrait.webp b/android/src/main/res/drawable-xhdpi/femal_doctor_with_phone_small_portrait.webp new file mode 100644 index 00000000..6d32017e Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/femal_doctor_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/girl_red_oh_no.webp b/android/src/main/res/drawable-xhdpi/girl_red_oh_no.webp new file mode 100644 index 00000000..976e7f2a Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/girl_red_oh_no.webp differ diff --git a/android/src/main/res/drawable-xhdpi/grand_father_portrait.webp b/android/src/main/res/drawable-xhdpi/grand_father_portrait.webp new file mode 100644 index 00000000..e0e7ddc5 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/grand_father_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/grand_father_small_portrait.webp b/android/src/main/res/drawable-xhdpi/grand_father_small_portrait.webp new file mode 100644 index 00000000..fe866469 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/grand_father_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/grand_mother_portrait.webp b/android/src/main/res/drawable-xhdpi/grand_mother_portrait.webp new file mode 100644 index 00000000..2c4e2dfb Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/grand_mother_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/grand_mother_small_portrait.webp b/android/src/main/res/drawable-xhdpi/grand_mother_small_portrait.webp new file mode 100644 index 00000000..08743555 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/grand_mother_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/health_card_hint_blue.webp b/android/src/main/res/drawable-xhdpi/health_card_hint_blue.webp deleted file mode 100644 index b0626463..00000000 Binary files a/android/src/main/res/drawable-xhdpi/health_card_hint_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/laptop_woman_blue.webp b/android/src/main/res/drawable-xhdpi/laptop_woman_blue.webp deleted file mode 100644 index 26ba80c2..00000000 Binary files a/android/src/main/res/drawable-xhdpi/laptop_woman_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_gray_large.webp b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_gray_large.webp new file mode 100644 index 00000000..f2c3972e Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_gray_large.webp differ diff --git a/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_gray_small.webp b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_gray_small.webp new file mode 100644 index 00000000..d0e1bd9d Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_gray_small.webp differ diff --git a/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_large.webp b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_large.webp new file mode 100644 index 00000000..e4d1faa4 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_large.webp differ diff --git a/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_small.webp b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_small.webp new file mode 100644 index 00000000..f1471dea Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/main_screen_erx_icon_small.webp differ diff --git a/android/src/main/res/drawable-xhdpi/man_register.webp b/android/src/main/res/drawable-xhdpi/man_register.webp deleted file mode 100644 index 011d5b6b..00000000 Binary files a/android/src/main/res/drawable-xhdpi/man_register.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/man_with_phone_portrait.webp b/android/src/main/res/drawable-xhdpi/man_with_phone_portrait.webp new file mode 100644 index 00000000..d583fc68 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/man_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/man_with_phone_small_portrait.webp b/android/src/main/res/drawable-xhdpi/man_with_phone_small_portrait.webp new file mode 100644 index 00000000..4dd0f96f Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/man_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/maps_marker.webp b/android/src/main/res/drawable-xhdpi/maps_marker.webp new file mode 100644 index 00000000..d4825289 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/maps_marker.webp differ diff --git a/android/src/main/res/drawable-xhdpi/medical_hand_out_circle_blue.webp b/android/src/main/res/drawable-xhdpi/medical_hand_out_circle_blue.webp deleted file mode 100644 index 1feb388d..00000000 Binary files a/android/src/main/res/drawable-xhdpi/medical_hand_out_circle_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/my_location.webp b/android/src/main/res/drawable-xhdpi/my_location.webp new file mode 100644 index 00000000..d3fa06c3 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/my_location.webp differ diff --git a/android/src/main/res/drawable-xhdpi/oh_no.webp b/android/src/main/res/drawable-xhdpi/oh_no.webp deleted file mode 100644 index b1786921..00000000 Binary files a/android/src/main/res/drawable-xhdpi/oh_no.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/old_man_of_color_portrait.webp b/android/src/main/res/drawable-xhdpi/old_man_of_color_portrait.webp new file mode 100644 index 00000000..03e91c7e Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/old_man_of_color_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/old_man_of_color_small_portrait.webp b/android/src/main/res/drawable-xhdpi/old_man_of_color_small_portrait.webp new file mode 100644 index 00000000..4cf186c4 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/old_man_of_color_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/onboarding_healthcard.webp b/android/src/main/res/drawable-xhdpi/onboarding_healthcard.webp deleted file mode 100644 index 4855cc44..00000000 Binary files a/android/src/main/res/drawable-xhdpi/onboarding_healthcard.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/onboarding_pharmacist.webp b/android/src/main/res/drawable-xhdpi/onboarding_pharmacist.webp deleted file mode 100644 index 3a10781a..00000000 Binary files a/android/src/main/res/drawable-xhdpi/onboarding_pharmacist.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/paragraph.webp b/android/src/main/res/drawable-xhdpi/paragraph.webp new file mode 100644 index 00000000..95088395 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/paragraph.webp differ diff --git a/android/src/main/res/drawable-xhdpi/pharmacist_2.webp b/android/src/main/res/drawable-xhdpi/pharmacist_2.webp deleted file mode 100644 index e8543767..00000000 Binary files a/android/src/main/res/drawable-xhdpi/pharmacist_2.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/pharmacist_circle.webp b/android/src/main/res/drawable-xhdpi/pharmacist_circle.webp deleted file mode 100644 index 3768eb8f..00000000 Binary files a/android/src/main/res/drawable-xhdpi/pharmacist_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/pharmacist_with_phone_hint_blue.webp b/android/src/main/res/drawable-xhdpi/pharmacist_with_phone_hint_blue.webp deleted file mode 100644 index 150dffd7..00000000 Binary files a/android/src/main/res/drawable-xhdpi/pharmacist_with_phone_hint_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xhdpi/prescription.webp b/android/src/main/res/drawable-xhdpi/prescription.webp new file mode 100644 index 00000000..ec4048a0 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/prescription.webp differ diff --git a/android/src/main/res/drawable-xhdpi/wheel_chair_user_portrait.webp b/android/src/main/res/drawable-xhdpi/wheel_chair_user_portrait.webp new file mode 100644 index 00000000..9cb39e79 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/wheel_chair_user_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/wheel_chair_user_small_portrait.webp b/android/src/main/res/drawable-xhdpi/wheel_chair_user_small_portrait.webp new file mode 100644 index 00000000..9160cd16 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/wheel_chair_user_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/woman_with_head_scarf_portrait.webp b/android/src/main/res/drawable-xhdpi/woman_with_head_scarf_portrait.webp new file mode 100644 index 00000000..997dbae5 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/woman_with_head_scarf_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/woman_with_head_scarf_small_portrait.webp b/android/src/main/res/drawable-xhdpi/woman_with_head_scarf_small_portrait.webp new file mode 100644 index 00000000..3da577ee Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/woman_with_head_scarf_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/woman_with_phone_portrait.webp b/android/src/main/res/drawable-xhdpi/woman_with_phone_portrait.webp new file mode 100644 index 00000000..17846b61 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/woman_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xhdpi/woman_with_phone_small_portrait.webp b/android/src/main/res/drawable-xhdpi/woman_with_phone_small_portrait.webp new file mode 100644 index 00000000..00d6d579 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/woman_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/.webp b/android/src/main/res/drawable-xxhdpi/.webp new file mode 100644 index 00000000..2b892aaa Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/alarm_clock.webp b/android/src/main/res/drawable-xxhdpi/alarm_clock.webp new file mode 100644 index 00000000..2d2babac Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/alarm_clock.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/baby_portrait.webp b/android/src/main/res/drawable-xxhdpi/baby_portrait.webp new file mode 100644 index 00000000..3fac46d7 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/baby_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/baby_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/baby_small_portrait.webp new file mode 100644 index 00000000..85b22318 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/baby_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/boy_green_shirt_card_circle.webp b/android/src/main/res/drawable-xxhdpi/boy_green_shirt_card_circle.webp deleted file mode 100644 index 5ddf7326..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/boy_green_shirt_card_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/boy_with_health_card_portrait.webp b/android/src/main/res/drawable-xxhdpi/boy_with_health_card_portrait.webp new file mode 100644 index 00000000..00d52e78 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/boy_with_health_card_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/boy_with_health_card_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/boy_with_health_card_small_portrait.webp new file mode 100644 index 00000000..075ad72d Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/boy_with_health_card_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/calling_lady.webp b/android/src/main/res/drawable-xxhdpi/calling_lady.webp deleted file mode 100644 index a5bdef2d..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/calling_lady.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/card_wall_card_can.webp b/android/src/main/res/drawable-xxhdpi/card_wall_card_can.webp index 91fba03f..1a9f7cf1 100644 Binary files a/android/src/main/res/drawable-xxhdpi/card_wall_card_can.webp and b/android/src/main/res/drawable-xxhdpi/card_wall_card_can.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/card_wall_man.webp b/android/src/main/res/drawable-xxhdpi/card_wall_man.webp deleted file mode 100644 index 647b98dc..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/card_wall_man.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/clapping_hands.webp b/android/src/main/res/drawable-xxhdpi/clapping_hands.webp deleted file mode 100644 index 37203363..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/clapping_hands.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/clapping_hands_blue.webp b/android/src/main/res/drawable-xxhdpi/clapping_hands_blue.webp new file mode 100644 index 00000000..8f5cf247 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/clapping_hands_blue.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/clapping_hands_hint_yellow.webp b/android/src/main/res/drawable-xxhdpi/clapping_hands_hint_yellow.webp deleted file mode 100644 index 7fd7e5aa..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/clapping_hands_hint_yellow.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/connect_profile.webp b/android/src/main/res/drawable-xxhdpi/connect_profile.webp deleted file mode 100644 index 42b735ab..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/connect_profile.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/developer.webp b/android/src/main/res/drawable-xxhdpi/developer.webp new file mode 100644 index 00000000..ccce9a6c Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/developer.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/doctor_circle.webp b/android/src/main/res/drawable-xxhdpi/doctor_circle.webp deleted file mode 100644 index a9926f71..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/doctor_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/doctor_with_phone_portrait.webp b/android/src/main/res/drawable-xxhdpi/doctor_with_phone_portrait.webp new file mode 100644 index 00000000..21c6548c Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/doctor_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/doctor_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/doctor_with_phone_small_portrait.webp new file mode 100644 index 00000000..deb6f326 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/doctor_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/femal_developer_portrait.webp b/android/src/main/res/drawable-xxhdpi/femal_developer_portrait.webp new file mode 100644 index 00000000..08a04436 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/femal_developer_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/femal_developer_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/femal_developer_small_portrait.webp new file mode 100644 index 00000000..73cf32de Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/femal_developer_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/femal_doctor_portrait.webp b/android/src/main/res/drawable-xxhdpi/femal_doctor_portrait.webp new file mode 100644 index 00000000..2db1dafb Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/femal_doctor_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/femal_doctor_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/femal_doctor_small_portrait.webp new file mode 100644 index 00000000..2b892aaa Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/femal_doctor_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/femal_doctor_with_phone_portrait.webp b/android/src/main/res/drawable-xxhdpi/femal_doctor_with_phone_portrait.webp new file mode 100644 index 00000000..3f88d73b Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/femal_doctor_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/femal_doctor_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/femal_doctor_with_phone_small_portrait.webp new file mode 100644 index 00000000..48f9f710 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/femal_doctor_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/girl_red_oh_no.webp b/android/src/main/res/drawable-xxhdpi/girl_red_oh_no.webp new file mode 100644 index 00000000..de395a79 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/girl_red_oh_no.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/grand_father_portrait.webp b/android/src/main/res/drawable-xxhdpi/grand_father_portrait.webp new file mode 100644 index 00000000..d7e079d9 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/grand_father_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/grand_father_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/grand_father_small_portrait.webp new file mode 100644 index 00000000..ec009c1c Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/grand_father_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/grand_mother_portrait.webp b/android/src/main/res/drawable-xxhdpi/grand_mother_portrait.webp new file mode 100644 index 00000000..448dea92 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/grand_mother_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/grand_mother_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/grand_mother_small_portrait.webp new file mode 100644 index 00000000..7251f737 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/grand_mother_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/health_card_hint_blue.webp b/android/src/main/res/drawable-xxhdpi/health_card_hint_blue.webp deleted file mode 100644 index 9aae6318..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/health_card_hint_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/laptop_woman_blue.webp b/android/src/main/res/drawable-xxhdpi/laptop_woman_blue.webp deleted file mode 100644 index 39dde686..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/laptop_woman_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_gray_large.webp b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_gray_large.webp new file mode 100644 index 00000000..fe571a4f Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_gray_large.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_gray_small.webp b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_gray_small.webp new file mode 100644 index 00000000..1b6ba973 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_gray_small.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_large.webp b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_large.webp new file mode 100644 index 00000000..76a06c24 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_large.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_small.webp b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_small.webp new file mode 100644 index 00000000..055e0d42 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/main_screen_erx_icon_small.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/man_register.webp b/android/src/main/res/drawable-xxhdpi/man_register.webp deleted file mode 100644 index 4cf207a7..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/man_register.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/man_with_phone_portrait.webp b/android/src/main/res/drawable-xxhdpi/man_with_phone_portrait.webp new file mode 100644 index 00000000..da037b96 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/man_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/man_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/man_with_phone_small_portrait.webp new file mode 100644 index 00000000..2f80adee Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/man_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/maps_marker.webp b/android/src/main/res/drawable-xxhdpi/maps_marker.webp new file mode 100644 index 00000000..ec928133 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/maps_marker.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/medical_hand_out_circle_blue.webp b/android/src/main/res/drawable-xxhdpi/medical_hand_out_circle_blue.webp deleted file mode 100644 index ba7c0c75..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/medical_hand_out_circle_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/my_location.webp b/android/src/main/res/drawable-xxhdpi/my_location.webp new file mode 100644 index 00000000..bafa9239 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/my_location.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/oh_no.webp b/android/src/main/res/drawable-xxhdpi/oh_no.webp deleted file mode 100644 index dd1e26ac..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/oh_no.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/old_man_of_color_portrait.webp b/android/src/main/res/drawable-xxhdpi/old_man_of_color_portrait.webp new file mode 100644 index 00000000..4c1dcb0d Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/old_man_of_color_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/old_man_of_color_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/old_man_of_color_small_portrait.webp new file mode 100644 index 00000000..659c936c Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/old_man_of_color_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/onboarding_healthcard.webp b/android/src/main/res/drawable-xxhdpi/onboarding_healthcard.webp deleted file mode 100644 index 5ecc620f..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/onboarding_healthcard.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/onboarding_pharmacist.webp b/android/src/main/res/drawable-xxhdpi/onboarding_pharmacist.webp deleted file mode 100644 index e314410a..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/onboarding_pharmacist.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/paragraph.webp b/android/src/main/res/drawable-xxhdpi/paragraph.webp new file mode 100644 index 00000000..c6ec70ea Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/paragraph.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/pharmacist_2.webp b/android/src/main/res/drawable-xxhdpi/pharmacist_2.webp deleted file mode 100644 index e84b5c30..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/pharmacist_2.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/pharmacist_circle.webp b/android/src/main/res/drawable-xxhdpi/pharmacist_circle.webp deleted file mode 100644 index c584d11f..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/pharmacist_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/pharmacist_with_phone_hint_blue.webp b/android/src/main/res/drawable-xxhdpi/pharmacist_with_phone_hint_blue.webp deleted file mode 100644 index 0296d96f..00000000 Binary files a/android/src/main/res/drawable-xxhdpi/pharmacist_with_phone_hint_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxhdpi/prescription.webp b/android/src/main/res/drawable-xxhdpi/prescription.webp new file mode 100644 index 00000000..3299cafd Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/prescription.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/wheel_chair_user_portrait.webp b/android/src/main/res/drawable-xxhdpi/wheel_chair_user_portrait.webp new file mode 100644 index 00000000..dc15fec5 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/wheel_chair_user_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/wheel_chair_user_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/wheel_chair_user_small_portrait.webp new file mode 100644 index 00000000..04ddce0c Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/wheel_chair_user_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/woman_with_head_scarf_portrait.webp b/android/src/main/res/drawable-xxhdpi/woman_with_head_scarf_portrait.webp new file mode 100644 index 00000000..60e58a19 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/woman_with_head_scarf_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/woman_with_head_scarf_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/woman_with_head_scarf_small_portrait.webp new file mode 100644 index 00000000..eb6ce8f7 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/woman_with_head_scarf_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/woman_with_phone_portrait.webp b/android/src/main/res/drawable-xxhdpi/woman_with_phone_portrait.webp new file mode 100644 index 00000000..5b7f2441 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/woman_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxhdpi/woman_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxhdpi/woman_with_phone_small_portrait.webp new file mode 100644 index 00000000..9d902e38 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/woman_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/.webp b/android/src/main/res/drawable-xxxhdpi/.webp new file mode 100644 index 00000000..cf7af08a Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/alarm_clock.webp b/android/src/main/res/drawable-xxxhdpi/alarm_clock.webp new file mode 100644 index 00000000..d051da12 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/alarm_clock.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/baby_portrait.webp b/android/src/main/res/drawable-xxxhdpi/baby_portrait.webp new file mode 100644 index 00000000..bfcace19 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/baby_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/baby_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/baby_small_portrait.webp new file mode 100644 index 00000000..8f80a996 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/baby_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/boy_green_shirt_card_circle.webp b/android/src/main/res/drawable-xxxhdpi/boy_green_shirt_card_circle.webp deleted file mode 100644 index 8198b6f6..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/boy_green_shirt_card_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/boy_with_health_card_portrait.webp b/android/src/main/res/drawable-xxxhdpi/boy_with_health_card_portrait.webp new file mode 100644 index 00000000..118ad406 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/boy_with_health_card_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/boy_with_health_card_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/boy_with_health_card_small_portrait.webp new file mode 100644 index 00000000..1f2571fa Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/boy_with_health_card_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/calling_lady.webp b/android/src/main/res/drawable-xxxhdpi/calling_lady.webp deleted file mode 100644 index b245e1e2..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/calling_lady.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/card_wall_card_can.webp b/android/src/main/res/drawable-xxxhdpi/card_wall_card_can.webp index 1db2e872..189e2ceb 100644 Binary files a/android/src/main/res/drawable-xxxhdpi/card_wall_card_can.webp and b/android/src/main/res/drawable-xxxhdpi/card_wall_card_can.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/card_wall_man.webp b/android/src/main/res/drawable-xxxhdpi/card_wall_man.webp deleted file mode 100644 index 075a1967..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/card_wall_man.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/clapping_hands.webp b/android/src/main/res/drawable-xxxhdpi/clapping_hands.webp deleted file mode 100644 index 5fec1ce6..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/clapping_hands.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/clapping_hands_blue.webp b/android/src/main/res/drawable-xxxhdpi/clapping_hands_blue.webp new file mode 100644 index 00000000..3d31b603 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/clapping_hands_blue.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/clapping_hands_hint_yellow.webp b/android/src/main/res/drawable-xxxhdpi/clapping_hands_hint_yellow.webp deleted file mode 100644 index d56ad77d..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/clapping_hands_hint_yellow.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/connect_profile.webp b/android/src/main/res/drawable-xxxhdpi/connect_profile.webp deleted file mode 100644 index 82c9a814..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/connect_profile.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/developer.webp b/android/src/main/res/drawable-xxxhdpi/developer.webp new file mode 100644 index 00000000..29c0edae Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/developer.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/doctor_circle.webp b/android/src/main/res/drawable-xxxhdpi/doctor_circle.webp deleted file mode 100644 index a9926f71..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/doctor_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/doctor_with_phone_portrait.webp b/android/src/main/res/drawable-xxxhdpi/doctor_with_phone_portrait.webp new file mode 100644 index 00000000..bba8b7ba Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/doctor_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/doctor_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/doctor_with_phone_small_portrait.webp new file mode 100644 index 00000000..08b253a2 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/doctor_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/femal_developer_portrait.webp b/android/src/main/res/drawable-xxxhdpi/femal_developer_portrait.webp new file mode 100644 index 00000000..cdfad01b Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/femal_developer_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/femal_developer_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/femal_developer_small_portrait.webp new file mode 100644 index 00000000..6f9a5e91 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/femal_developer_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/femal_doctor_portrait.webp b/android/src/main/res/drawable-xxxhdpi/femal_doctor_portrait.webp new file mode 100644 index 00000000..ede85257 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/femal_doctor_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/femal_doctor_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/femal_doctor_small_portrait.webp new file mode 100644 index 00000000..cf7af08a Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/femal_doctor_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/femal_doctor_with_phone_portrait.webp b/android/src/main/res/drawable-xxxhdpi/femal_doctor_with_phone_portrait.webp new file mode 100644 index 00000000..db474433 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/femal_doctor_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/femal_doctor_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/femal_doctor_with_phone_small_portrait.webp new file mode 100644 index 00000000..822fa084 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/femal_doctor_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/girl_red_oh_no.webp b/android/src/main/res/drawable-xxxhdpi/girl_red_oh_no.webp new file mode 100644 index 00000000..5b3943eb Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/girl_red_oh_no.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/grand_father_portrait.webp b/android/src/main/res/drawable-xxxhdpi/grand_father_portrait.webp new file mode 100644 index 00000000..a89d467a Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/grand_father_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/grand_father_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/grand_father_small_portrait.webp new file mode 100644 index 00000000..31a9b3e1 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/grand_father_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/grand_mother_portrait.webp b/android/src/main/res/drawable-xxxhdpi/grand_mother_portrait.webp new file mode 100644 index 00000000..68239725 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/grand_mother_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/grand_mother_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/grand_mother_small_portrait.webp new file mode 100644 index 00000000..0f85e2df Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/grand_mother_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/health_card_hint_blue.webp b/android/src/main/res/drawable-xxxhdpi/health_card_hint_blue.webp deleted file mode 100644 index 13a88f05..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/health_card_hint_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/laptop_woman_blue.webp b/android/src/main/res/drawable-xxxhdpi/laptop_woman_blue.webp deleted file mode 100644 index b3f4b0c7..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/laptop_woman_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_gray_large.webp b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_gray_large.webp new file mode 100644 index 00000000..e86d21ae Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_gray_large.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_gray_small.webp b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_gray_small.webp new file mode 100644 index 00000000..a2294755 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_gray_small.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_large.webp b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_large.webp new file mode 100644 index 00000000..4637fb47 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_large.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_small.webp b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_small.webp new file mode 100644 index 00000000..2d363607 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/main_screen_erx_icon_small.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/man_register.webp b/android/src/main/res/drawable-xxxhdpi/man_register.webp deleted file mode 100644 index 46a15238..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/man_register.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/man_with_phone_portrait.webp b/android/src/main/res/drawable-xxxhdpi/man_with_phone_portrait.webp new file mode 100644 index 00000000..0b203dff Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/man_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/man_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/man_with_phone_small_portrait.webp new file mode 100644 index 00000000..911d92f2 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/man_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/maps_marker.webp b/android/src/main/res/drawable-xxxhdpi/maps_marker.webp new file mode 100644 index 00000000..76262610 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/maps_marker.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/medical_hand_out_circle_blue.webp b/android/src/main/res/drawable-xxxhdpi/medical_hand_out_circle_blue.webp deleted file mode 100644 index 96c79e0e..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/medical_hand_out_circle_blue.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/my_location.webp b/android/src/main/res/drawable-xxxhdpi/my_location.webp new file mode 100644 index 00000000..44027472 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/my_location.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/oh_no.webp b/android/src/main/res/drawable-xxxhdpi/oh_no.webp deleted file mode 100644 index e12d41bd..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/oh_no.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/old_man_of_color_portrait.webp b/android/src/main/res/drawable-xxxhdpi/old_man_of_color_portrait.webp new file mode 100644 index 00000000..a3265c20 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/old_man_of_color_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/old_man_of_color_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/old_man_of_color_small_portrait.webp new file mode 100644 index 00000000..0c78f0c9 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/old_man_of_color_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/onboarding_healthcard.webp b/android/src/main/res/drawable-xxxhdpi/onboarding_healthcard.webp deleted file mode 100644 index 04c493b0..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/onboarding_healthcard.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/onboarding_pharmacist.webp b/android/src/main/res/drawable-xxxhdpi/onboarding_pharmacist.webp deleted file mode 100644 index cb792d63..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/onboarding_pharmacist.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/paragraph.webp b/android/src/main/res/drawable-xxxhdpi/paragraph.webp new file mode 100644 index 00000000..862bfdfd Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/paragraph.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/pharmacist_2.webp b/android/src/main/res/drawable-xxxhdpi/pharmacist_2.webp deleted file mode 100644 index c7ea0844..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/pharmacist_2.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/pharmacist_circle.webp b/android/src/main/res/drawable-xxxhdpi/pharmacist_circle.webp deleted file mode 100644 index 37d2ad4b..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/pharmacist_circle.webp and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/pharmacist_with_phone_hint_blue.png b/android/src/main/res/drawable-xxxhdpi/pharmacist_with_phone_hint_blue.png deleted file mode 100644 index f89734a4..00000000 Binary files a/android/src/main/res/drawable-xxxhdpi/pharmacist_with_phone_hint_blue.png and /dev/null differ diff --git a/android/src/main/res/drawable-xxxhdpi/prescription.webp b/android/src/main/res/drawable-xxxhdpi/prescription.webp new file mode 100644 index 00000000..9e3cfc52 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/prescription.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/wheel_chair_user_portrait.webp b/android/src/main/res/drawable-xxxhdpi/wheel_chair_user_portrait.webp new file mode 100644 index 00000000..59ef366d Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/wheel_chair_user_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/wheel_chair_user_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/wheel_chair_user_small_portrait.webp new file mode 100644 index 00000000..183e9202 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/wheel_chair_user_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/woman_with_head_scarf_portrait.webp b/android/src/main/res/drawable-xxxhdpi/woman_with_head_scarf_portrait.webp new file mode 100644 index 00000000..ca552b1a Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/woman_with_head_scarf_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/woman_with_head_scarf_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/woman_with_head_scarf_small_portrait.webp new file mode 100644 index 00000000..2fe91f94 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/woman_with_head_scarf_small_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/woman_with_phone_portrait.webp b/android/src/main/res/drawable-xxxhdpi/woman_with_phone_portrait.webp new file mode 100644 index 00000000..cc2da161 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/woman_with_phone_portrait.webp differ diff --git a/android/src/main/res/drawable-xxxhdpi/woman_with_phone_small_portrait.webp b/android/src/main/res/drawable-xxxhdpi/woman_with_phone_small_portrait.webp new file mode 100644 index 00000000..2c167915 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/woman_with_phone_small_portrait.webp differ diff --git a/android/src/main/res/drawable/ic_construction_android.xml b/android/src/main/res/drawable/ic_construction_android.xml deleted file mode 100644 index 49591e37..00000000 --- a/android/src/main/res/drawable/ic_construction_android.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/src/main/res/drawable/ic_german_flag.xml b/android/src/main/res/drawable/ic_german_flag.xml deleted file mode 100644 index e9d1d1ad..00000000 --- a/android/src/main/res/drawable/ic_german_flag.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/android/src/main/res/drawable/ic_green_cross.xml b/android/src/main/res/drawable/ic_green_cross.xml new file mode 100644 index 00000000..ba9dab90 --- /dev/null +++ b/android/src/main/res/drawable/ic_green_cross.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/src/main/res/drawable/ic_healthcard.xml b/android/src/main/res/drawable/ic_healthcard.xml index 8728f187..80630a56 100644 --- a/android/src/main/res/drawable/ic_healthcard.xml +++ b/android/src/main/res/drawable/ic_healthcard.xml @@ -1,38 +1,38 @@ - - - - - - - - - - + android:width="144dp" + android:height="96dp" + android:viewportWidth="144" + android:viewportHeight="96"> + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_order_egk.xml b/android/src/main/res/drawable/ic_order_egk.xml new file mode 100644 index 00000000..4af12c54 --- /dev/null +++ b/android/src/main/res/drawable/ic_order_egk.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_reset_pin.xml b/android/src/main/res/drawable/ic_reset_pin.xml new file mode 100644 index 00000000..29f6cbc9 --- /dev/null +++ b/android/src/main/res/drawable/ic_reset_pin.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_step_1.xml b/android/src/main/res/drawable/ic_step_1.xml deleted file mode 100644 index 46f27a83..00000000 --- a/android/src/main/res/drawable/ic_step_1.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/android/src/main/res/drawable/ic_step_2.xml b/android/src/main/res/drawable/ic_step_2.xml deleted file mode 100644 index 27bd4e72..00000000 --- a/android/src/main/res/drawable/ic_step_2.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/android/src/main/res/drawable/ic_step_3.xml b/android/src/main/res/drawable/ic_step_3.xml deleted file mode 100644 index 170bbdbf..00000000 --- a/android/src/main/res/drawable/ic_step_3.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/android/src/main/res/font/noto_sans_bold.ttf b/android/src/main/res/font/noto_sans_bold.ttf new file mode 100644 index 00000000..1db7886e Binary files /dev/null and b/android/src/main/res/font/noto_sans_bold.ttf differ diff --git a/android/src/main/res/font/noto_sans_medium.ttf b/android/src/main/res/font/noto_sans_medium.ttf new file mode 100644 index 00000000..5dbefd37 Binary files /dev/null and b/android/src/main/res/font/noto_sans_medium.ttf differ diff --git a/android/src/main/res/font/noto_sans_regular.ttf b/android/src/main/res/font/noto_sans_regular.ttf new file mode 100644 index 00000000..0a01a062 Binary files /dev/null and b/android/src/main/res/font/noto_sans_regular.ttf differ diff --git a/android/src/main/res/font/noto_sans_semibold.ttf b/android/src/main/res/font/noto_sans_semibold.ttf new file mode 100644 index 00000000..8b7fd130 Binary files /dev/null and b/android/src/main/res/font/noto_sans_semibold.ttf differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..2341c4e9 Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..4b1a97a8 Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..753501ab Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/raw/animation_cdw_instruction.webm b/android/src/main/res/raw/animation_cdw_instruction.webm deleted file mode 100644 index 6570fdb9..00000000 Binary files a/android/src/main/res/raw/animation_cdw_instruction.webm and /dev/null differ diff --git a/android/src/main/res/raw/animation_courier.webm b/android/src/main/res/raw/animation_courier.webm index 07d0cf92..449c50b1 100644 Binary files a/android/src/main/res/raw/animation_courier.webm and b/android/src/main/res/raw/animation_courier.webm differ diff --git a/android/src/main/res/raw/animation_local.webm b/android/src/main/res/raw/animation_local.webm index 7591a8a5..3e1f93e3 100644 Binary files a/android/src/main/res/raw/animation_local.webm and b/android/src/main/res/raw/animation_local.webm differ diff --git a/android/src/main/res/raw/animation_mail.webm b/android/src/main/res/raw/animation_mail.webm index cd63364b..871ffac0 100644 Binary files a/android/src/main/res/raw/animation_mail.webm and b/android/src/main/res/raw/animation_mail.webm differ diff --git a/android/src/main/res/raw/animation_pulse_lottie.json b/android/src/main/res/raw/animation_pulse_lottie.json new file mode 100644 index 00000000..80cf57e7 --- /dev/null +++ b/android/src/main/res/raw/animation_pulse_lottie.json @@ -0,0 +1 @@ +{"v":"5.1.16","fr":30,"ip":0,"op":60,"w":360,"h":360,"nm":"Pre-comp 2","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":16,"s":[0],"e":[40]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":46,"s":[40],"e":[0]},{"t":76.0000030955435}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[100.309,99.021,0],"ix":2},"a":{"a":0,"k":[0.309,-0.979,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":16,"s":[0,0,100],"e":[100,100,100]},{"t":76.0000030955435}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[148.156,148.156],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2627450980392157,0.6,0.8823529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0.309,-0.979],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":16.0000006516934,"op":76.0000030955435,"st":16.0000006516934,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":0,"s":[0],"e":[40]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"n":["0p667_1_0p333_0"],"t":30,"s":[40],"e":[0]},{"t":60.0000024438501}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[100.309,99.021,0],"ix":2},"a":{"a":0,"k":[0.309,-0.979,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0","0p667_1_0p333_0"],"t":0,"s":[0,0,100],"e":[100,100,100]},{"t":60.0000024438501}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[148.156,148.156],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2627450980392157,0.6,0.8823529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0.309,-0.979],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":76.0000030955435,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 1","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[180,180,0],"ix":2},"a":{"a":0,"k":[100,100,0],"ix":1},"s":{"a":0,"k":[180,180,100],"ix":6}},"ao":0,"w":200,"h":200,"ip":0,"op":39.0000015885026,"st":-37.0000015070409,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 1","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[180,180,0],"ix":2},"a":{"a":0,"k":[100,100,0],"ix":1},"s":{"a":0,"k":[180,180,100],"ix":6}},"ao":0,"w":200,"h":200,"ip":23.0000009368092,"op":60.0000024438501,"st":23.0000009368092,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/android/src/main/res/raw/device_lottie.json b/android/src/main/res/raw/device_lottie.json new file mode 100644 index 00000000..8b9f49fb --- /dev/null +++ b/android/src/main/res/raw/device_lottie.json @@ -0,0 +1 @@ +{"v":"5.6.6","ip":0,"op":1,"fr":60,"w":241,"h":146,"layers":[{"ind":1899,"nm":"surface8209","ao":0,"ip":0,"op":60,"st":0,"ty":4,"ks":{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[133.33,133.33]},"sk":{"k":0},"sa":{"k":0}},"shapes":[{"ty":"gr","hd":false,"nm":"surface8209","it":[{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.05,0.06],[0,0.08],[-0.05,0.06],[-0.07,0.02],[-0.21,0],[-0.19,-0.1],[-0.05,-0.07],[0,-0.08],[0.05,-0.07],[0.08,-0.02],[0.21,0],[0.19,0.1]],"o":[[-0.07,-0.02],[-0.05,-0.07],[0,-0.08],[0.05,-0.07],[0.19,-0.1],[0.21,0],[0.08,0.02],[0.05,0.06],[0,0.08],[-0.05,0.06],[-0.19,0.1],[-0.21,0],[0,0]],"v":[[149.08,18.38],[148.89,18.25],[148.82,18.03],[148.89,17.81],[149.08,17.67],[149.7,17.52],[150.31,17.67],[150.5,17.81],[150.57,18.03],[150.5,18.25],[150.31,18.38],[149.7,18.53],[149.08,18.38]],"c":true}}},{"ty":"fl","o":{"k":10},"c":{"k":[0.26,0.6,0.88,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0.66],[1.15,0],[0,-0.66],[-1.15,0]],"o":[[1.15,0],[0,-0.66],[-1.15,0],[0,0.66],[0,0]],"v":[[149.7,19.23],[151.78,18.03],[149.7,16.82],[147.62,18.03],[149.7,19.23]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.75,0.89,0.97,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[2.75,1.16],[0.27,0.15],[0,0],[0.41,0.55],[-2.07,1.19],[0,0],[-2.75,-1.59],[0,0],[0.01,-0.01],[-2.14,-1.23],[-2.13,1.23],[0,0],[0,0],[2.75,-1.59]],"o":[[0,0],[-2.48,1.43],[-0.29,-0.12],[0,0],[-0.61,-0.32],[-1.04,-1.47],[0,0],[2.75,-1.59],[0,0],[-0.02,0],[-2.14,1.23],[2.14,1.23],[0,0],[0,0],[2.75,1.59],[0,0]],"v":[[172.89,35.74],[63.87,98.71],[54.75,99.12],[53.92,98.71],[10,73.32],[8.45,71.99],[10,67.58],[119.02,4.64],[128.97,4.64],[147.06,15.09],[147.02,15.11],[147.02,19.57],[154.76,19.57],[154.8,19.54],[172.89,29.99],[172.89,35.74]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.26,0.26,0.26,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[2.14,1.23],[-2.14,1.23],[0,0],[0,0],[2.75,-1.59],[0,0],[-2.75,-1.58],[0,0],[-2.75,1.59],[0,0],[2.75,1.59]],"o":[[0,0],[0,0],[-2.14,1.23],[-2.14,-1.23],[0,0],[0,0],[-2.75,-1.59],[0,0],[-2.75,1.59],[0,0],[2.75,1.59],[0,0],[2.75,-1.58],[0,0]],"v":[[172.89,30],[154.8,19.55],[154.75,19.57],[147.02,19.57],[147.02,15.11],[147.06,15.07],[128.97,4.62],[119.02,4.62],[9.99,67.58],[9.99,73.32],[53.91,98.69],[63.86,98.69],[172.89,35.74],[172.89,30]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[1,1,1,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-4.55,2.62],[0,0],[-3.92,-2.27],[0,0],[4.55,-2.62],[0,0],[3.93,2.27]],"o":[[0,0],[-3.93,-2.27],[0,0],[4.54,-2.61],[0,0],[3.93,2.27],[0,0],[-4.54,2.62],[0,0]],"v":[[49.51,103.04],[3.17,76.27],[4.29,67.43],[116.21,2.8],[131.52,2.16],[177.87,28.93],[176.75,37.77],[64.83,102.4],[49.51,103.04]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.13,0.13,0.13,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0.5],[-0.11,0.21],[-0.2,0.14],[0,0],[0,-0.5],[0.11,-0.21],[0.2,-0.14]],"o":[[0,0],[-0.37,0.21],[0,-0.24],[0.11,-0.22],[0,0],[0.37,-0.21],[0,0.24],[-0.11,0.21],[0,0]],"v":[[157.77,52.09],[152.07,55.38],[151.41,54.88],[151.59,54.18],[152.07,53.64],[157.77,50.35],[158.41,50.84],[158.24,51.54],[157.77,52.09]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[1,0.7,0.7,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0.49],[-0.11,0.22],[-0.2,0.14],[0,0],[0,-0.5],[0.11,-0.21],[0.2,-0.14]],"o":[[0,0],[-0.37,0.21],[0,-0.25],[0.11,-0.21],[0,0],[0.38,-0.21],[0,0.24],[-0.11,0.22],[0,0]],"v":[[166.49,47.04],[160.79,50.32],[160.14,49.83],[160.32,49.13],[160.79,48.58],[166.49,45.29],[167.14,45.79],[166.97,46.49],[166.49,47.04]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.96,0.96,0.96,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0.12,0.23],[0,0.26],[-0.39,-0.21],[0,0],[-0.12,-0.23],[0,-0.26],[0.39,0.22]],"o":[[0,0],[-0.21,-0.15],[-0.12,-0.23],[0,-0.52],[0,0],[0.21,0.15],[0.12,0.23],[0,0.52],[0,0]],"v":[[27.67,93.77],[21.54,90.23],[21.03,89.64],[20.84,88.89],[21.54,88.36],[27.67,91.9],[28.18,92.48],[28.37,93.24],[27.67,93.77]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.24,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[41.3,101.21],[41.3,100.21],[40.44,99.72],[40.44,100.71],[41.3,101.21]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.23,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[39.07,99.91],[39.07,98.92],[38.21,98.43],[38.21,99.42],[39.07,99.91]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.23,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[36.84,98.63],[36.84,97.64],[35.98,97.14],[35.98,98.14],[36.84,98.63]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.23,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[34.61,97.34],[34.61,96.34],[33.75,95.85],[33.75,96.84],[34.61,97.34]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.24,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[15.45,86.29],[15.46,85.29],[14.6,84.8],[14.59,85.79],[15.45,86.29]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.24,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[13.22,84.99],[13.23,84],[12.37,83.5],[12.36,84.5],[13.22,84.99]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.24,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[10.99,83.71],[11,82.71],[10.13,82.22],[10.13,83.21],[10.99,83.71]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0.24,0.41],[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14]],"o":[[0.24,-0.14],[-0.24,-0.41],[-0.24,0.14],[0.24,0.41],[0,0]],"v":[[8.76,82.41],[8.77,81.42],[7.9,80.93],[7.9,81.92],[8.76,82.41]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0,0,0,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-4.55,2.62],[0,0],[-3.92,-2.26],[0,0],[4.55,-2.62],[0,0],[3.93,2.27]],"o":[[0,0],[-3.93,-2.27],[0,0],[4.54,-2.6],[0,0],[3.93,2.27],[0,0],[-4.54,2.61],[0,0]],"v":[[49.51,103.69],[3.17,76.93],[4.29,68.08],[116.21,3.46],[131.52,2.83],[177.87,29.59],[176.75,38.43],[64.83,103.06],[49.51,103.69]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.26,0.26,0.26,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[0.52,76.65],[0.52,72.39],[5.52,74.27]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.13,0.13,0.13,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-4.55,2.62],[0,0],[-3.92,-2.27],[0,0],[4.55,-2.62],[0,0],[3.93,2.27]],"o":[[0,0],[-3.93,-2.27],[0,0],[4.54,-2.6],[0,0],[3.93,2.27],[0,0],[-4.54,2.62],[0,0]],"v":[[49.51,107.3],[3.17,80.54],[4.29,71.7],[116.21,7.08],[131.52,6.44],[177.87,33.21],[176.75,42.05],[64.83,106.67],[49.51,107.3]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.13,0.13,0.13,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[180.52,36.89],[180.52,32.79],[176.54,35.35]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.13,0.13,0.13,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]}]}],"meta":{"g":"LF SVG to Lottie"}} \ No newline at end of file diff --git a/android/src/main/res/raw/health_insurance_contacts.csv b/android/src/main/res/raw/health_insurance_contacts.csv deleted file mode 100644 index 002cd79f..00000000 --- a/android/src/main/res/raw/health_insurance_contacts.csv +++ /dev/null @@ -1,106 +0,0 @@ -name;healthCardAndPinPhone;healthCardAndPinMail;healthCardAndPinUrl;pinUrl -AOK Baden-Württemberg;;a99_egk@bw.aok.de;; -AOK Bayern;00498922844050;;; -AOK Bremen;004942117610;info@hb.aok.de;https://www.aok.de/pk/bremen/inhalt/elektronische-gesundheitskarte-3/; -AOK Bremerhaven;0049471160;info@hb.aok.de;https://www.aok.de/pk/bremen/inhalt/elektronische-gesundheitskarte-3/; -AOK - Die Gesundheitskasse Hessen;;service@he.aok.de;; -AOK - Die Gesundheitskasse Niedersachsen;00498000265637;;; -AOK Nordost;;eGK_online@nordost.aok.de;; -AOK Nordwest - Die Gesundheitskasse;00498002655060;;; -AOK PLUS - Die Gesundheitskasse für Sachsen und Thüringen;;;https://www.aok.de/pk/plus/inhalt/elektronische-gesundheitskarte-anfordern/;https://www.aok.de/pk/plus/inhalt/elektronische-gesundheitskarte-11/ -AOK Rheinland/Hamburg - Die Gesundheitskasse;;aok@rh.aok.de;; -AOK Rheinland-Pfalz/Saarland - Die Gesundheitskasse;;;https://www.aok.de/pk/rps/inhalt/die-haeufigsten-fragen-und-antworten-zum-e-rezept-4/; -AOK Sachsen-Anhalt - Die Gesundheitskasse;004908002265725;;; -Audi BKK;;;https://www.audibkk.de/e-rezept-gematik-egkpin/;https://www.audibkk.de/e-rezept-gematik-pin/ -BKK Deutsche Bahn AG;;;https://www.bahn-bkk.de/egk-erezept;https://www.bahn-bkk.de/egk-erezept -BKK Deutsche Bank AG;;;https://www.bkkdb.de/leistungen-beratung/alle-leistungen/alle-leistungen-von-a-z/versichertenkarte; -BARMER;00498003331010;;https://www.barmer.de/gematik-eRezept; -DIE BERGISCHE KRANKENKASSE;;;https://www.bergische-krankenkasse.de/digital/e-rezept;https://www.bergische-krankenkasse.de/digital/e-rezept -Bertelsmann BKK;;;https://www.bertelsmann-bkk.de/erezept-egk-pin;https://www.bertelsmann-bkk.de/erezept-egk-pin -BIG direkt gesund;004980054565456;;https://www.big-direkt.de/de/erezept-der-gematik-nutzen;https://www.big-direkt.de/de/erezept-der-gematik-nutzen -BKK Akzo Nobel Bayern;;;https://www.bkk-akzo.de/service/elektronische-gesundheitskarte-egk; -BKK B. Braun Aesculap;;;https://www.bkk-bba.de/egk-pin-bestellen;https://www.bkk-bba.de/pin-bestellen -BKK BPW Bergische Achsen KG;;;; -BKK EUREGIO;;;https://www.bkk-euregio.de/elektronische-gesundheitskarte;https://www.bkk-euregio.de/elektronische-gesundheitskarte -BKK EVM;;;; -BKK EWE;0049441350285108;versicherung@bkk-ewe.de;; -BKK Diakonie;0049521329876120;;; -BKK exklusiv;;;https://bkkexklusiv.de/gesundheitskarte; -BKK Faber-Castell & Partner;;erezept@bkk-faber-castell.de;; -BKK firmus;004942164343;;https://www.bkk-firmus.de/beratung-und-service/online-tools/e-rezept.html; -BKK Freudenberg;004962016905001;;; -BKK GILDEMEISTER SEIDENSTICKER;00498000255255;;https://www.bkkgs.de/e-rezept;https://www.bkkgs.de/e-rezept -BKK GRILLO-WERKE AG;;;; -BKK Groz-Beckert;;info@bkk-gb.de;; -BKK Herford Minden Ravensberg;;;; -BKK Herkules;0049561208550;info@bkk-herkules.de;https://www.bkk-herkules.de/service/gesundheitskarte-und-lichtbild/; -BKK HMR;004952211026210;;; -BKK KARL MAYER;;;; -BKK Linde;00496117366781;egk@bkk-linde.de;https://bkkln.de/epa-egkpin;https://bkkln.de/epa-egkpin -BKK Melitta HMR;004957197590;info@bkk-melitta.de;https://www.bkk-melitta.de/; -BKK MAHLE;;;https://www.bkk-mahle.de/service/elektronische-gesundheitskarte-egk; -BKK Miele;00498008002189;;https://www.miele-bkk.de/service/elektronische-gesundheitskarte; -BKK MTU;00497541907100;info@bkk-mtu.de;https://www.bkk-mtu.de/unsere-leistungen/leistungen-a-z/elektronische-gesundheitskarte-egk-bkk-mtu-service/; -BKK PFAFF;0049631318760;info@bkk-pfaff.de;; -BKK Pfalz;;;https://www.bkkpfalz.de/service-informationen/elektronische-gesundheitskarte; -BKK ProVita;00498006648808;;https://bkk-provita.de/service-info/e-rezept/; -BKK Public;00495341405600;service@bkk-public.de;; -BKK PricewaterhouseCoopers;00498002557920;erezept@bkk-pwc.de;; -BKK Rieker RICOSTA Weisser;004974625793030;;; -BKK RWE;;;https://www.bkkrwe.de/e-rezept;https://www.bkkrwe.de/e-rezept -BKK Salzgitter;00495341405700;service@bkk-salzgitter.de;; -BKK SBH;;;https://www.bkk-sbh.de/e-rezept/;https://bkk-sbh.de/e-rezept/ -BKK Scheufelen;;;https://www.bkk-scheufelen.de/e-rezept;https://www.bkk-scheufelen.de/e-rezept -BKK Schwarzwald-BaarHeuberg;;;; -BKK STADT AUGSBURG;00498213243231;;; -BKK Technoform;;;https://www.bkk-technoform.de/index.php?p=page&ID=11; -BKK Textilgruppe Hof;00498002558440;;; -BKK Verkehrsbau Union (VBU);;info@bkk-vbu.de;; -BKK VDN;0049230498260;;; -BKK VerbundPlus;;;https://www.bkk-verbundplus.de/ihre-mitgliedschaft/elektronische-gesundheitskarte/;https://www.bkk-verbundplus.de/nfc-karte-pin -BKK Voralb;004970229324639;beitraege@bkk-voralb.de;; -BKK Werra-Meissner;00490565174510;info@bkk-wm.de;; -BKK Wirtschaft & Finanzen;;;https://www.bkk-wf.de/e-rezept/; -BKK Würth;0049794091900;info@bkk-wuerth.de;; -BKK ZF & Partner;00493381306652512;;; -BKK_DürkoppAdler;00495215578470;eRezept@bkk-da.de;; -BKK24;;;https://www.bkk24.de/e-rezept;https://bkk24.de/e-rezept -BMW BKK;;;https://www.bmwbkk.de/egk;https://www.bmwbkk.de/egk-pin-puk -Bosch BKK;;info@bosch-bkk.de;https://meine.bosch-bkk.de/bitgo_gs/de/oeffentlich/login/login.xhtml;https://meine.bosch-bkk.de/bitgo_gs/de/oeffentlich/login/login.xhtml -Continentale BKK;00498006262626;kundenservice@continentale-bkk.de;https://www.continentale-bkk.de/kontakt/kontaktformular/;https://www.continentale-bkk.de/kontakt/kontaktformular/ -Daimler BKK;;;https://www.daimler-bkk.com/service/erezept;https://www.daimler-bkk.com/service/erezept -DAK-Gesundheit;;;; -Debeka BKK;;;;https://www.debeka-bkk.de/erezept/ -energie - Betriebskrankenkasse;;;; -Ernst & Young BKK;00495661707670;versicherung@ey-bkk.de;; -Heimat Krankenkasse;;;https://www.heimat-krankenkasse.de/egk-anfordern;https://www.heimat-krankenkasse.de/egk-pin-anfordern -HEK - Hanseatische Krankenkasse;00498000213213;;;https://www.hek.de/egk -Handelskrankenkasse (hkk);;;https://www.hkk.de/versicherung-und-tarife/allgemeine-infos/erezept-app;https://www.hkk.de/versicherung-und-tarife/allgemeine-infos/erezept-app -IKK - Die Innovationskasse;;;https://www.die-ik.de/e-rezept; -IKK Brandenburg und Berlin;;;https://www.ikkbb.de/erezept/auth-egk; -IKK classic;00498004551111;;; -IKK gesund plus;;;; -IKK Südwest;00498000119119;;https://www.ikk-suedwest.de/service/persoenlicher-kundenberater/;https://www.ikk-suedwest.de/service/persoenlicher-kundenberater/ -Kaufmännische Krankenkasse - KKH;00498005548640554;;; -KNAPPSCHAFT;00498000200501;;; -Koenig & Bauer BKK;;erezept@koenig-bauer-bkk.de;; -Krones BKK;;;; -Merck BKK;00496151722256;;; -mhplus Betriebskrankenkasse;;;https://www.mhplus-krankenkasse.de/privatkunden/unser-service/services-fuer-mitglieder/mhplus-gesundheitskarte;https://iam.mhplusdirekt.de/pinaas/pin-request-with-egk -Mobil Krankenkasse;00498002550800;;https://mobil-krankenkasse.de/unser-service/e-rezept.html; -Novitas BKK;00498006566300;gesundheitskarte@novitas-bkk.de;https://www.novitas-bkk.de/egk;https://www.novitas-bkk.de/egk -pronova BKK;0049621533911000;;https://www.pronovabkk.de/leistungen/elektronische-gesundheitskarte;https://meine.pronovabkk.de/ -R+V Betriebskrankenkasse;;;https://www.ruv-bkk.de/leistungen/alle-leistungen-im-ueberblick/leistungen-a-z/e/erezept/;https://www.ruv-bkk.de/leistungen/alle-leistungen-im-ueberblick/leistungen-a-z/e/erezept/ -Salus BKK;00498002213222;egk@salus-bkk.de;https://www.salus-bkk.de/service-formulare/infos-zur-mitgliedschaft/meine-gesundheitskarte/elektronische-gesundheitskarte/;https://www.salus-bkk.de/service-formulare/infos-zur-mitgliedschaft/meine-gesundheitskarte/elektronische-gesundheitskarte/ -SIEMAG BKK;00492733292929;info@siemagbkk.de;; -Siemens-Betriebskrankenkasse (SBK);;;https://meine.sbk.org/pin_gesundheitskarte; -SECURVITA BKK;00494033477;egk@securvita-bkk.de;; -SKD BKK;;;https://www.skd-bkk.de/leistungen/26-elektronische-gesundheitskarte-egk/; -Südzucker BKK;00496213285845;;; -Sozialversicherung für Landwirtschaft, Forsten und Gartenbau (SVLFG);00495617850;;https://portal.svlfg.de/svlfg-apps/gesundheitskarte; -Techniker Krankenkasse;;;https://www.tk.de/techniker/2113848;https://www.tk.de/techniker/2113852 -TUI BKK;00495341405800;service@tui-bkk.de;; -VIACTIV BKK;00498002221211;service@viactiv.de;; -vivida bkk;0049800375537555;Info@vividabkk.de;https://www.vividabkk.de/de/service/e-rezept-info;https://www.vividabkk.de/de/service/e-rezept-info -Wieland BKK;;;https://www.wieland-bkk.de/service/unsere-digitalen-moeglichkeiten/e-rezept;https://www.wieland-bkk.de/service/unsere-digitalen-moeglichkeiten/e-rezept -WMF Betriebskrankenkasse;;service@wmf-bkk.de;; diff --git a/android/src/main/res/raw/health_insurance_contacts.json b/android/src/main/res/raw/health_insurance_contacts.json new file mode 100644 index 00000000..3e776a22 --- /dev/null +++ b/android/src/main/res/raw/health_insurance_contacts.json @@ -0,0 +1,1157 @@ +[ + { + "name": "AOK Baden-Württemberg", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "a99_egk@bw.aok.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "AOK Bayern", + "healthCardAndPinPhone": "+498922844050", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK Bremen", + "healthCardAndPinPhone": "+4942117610", + "healthCardAndPinMail": "info@hb.aok.de", + "healthCardAndPinUrl": "https://www.aok.de/pk/bremen/inhalt/elektronische-gesundheitskarte-3/", + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "AOK Bremerhaven", + "healthCardAndPinPhone": "+49471160", + "healthCardAndPinMail": "info@hb.aok.de", + "healthCardAndPinUrl": "https://www.aok.de/pk/bremen/inhalt/elektronische-gesundheitskarte-3/", + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "AOK - Die Gesundheitskasse Hessen", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.aok.de/pk/versichertenservice/pin-zur-elektronischen-gesundheitskarte/?reg=hessen", + "pinUrl": "https://www.aok.de/pk/versichertenservice/pin-zur-elektronischen-gesundheitskarte/?reg=hessen", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK - Die Gesundheitskasse Niedersachsen", + "healthCardAndPinPhone": "+498000265637", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK Nordost", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "eGK_online@nordost.aok.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK Nordwest - Die Gesundheitskasse", + "healthCardAndPinPhone": "+498002655060", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK PLUS - Die Gesundheitskasse für Sachsen und Thüringen", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.aok.de/pk/plus/inhalt/elektronische-gesundheitskarte-anfordern/", + "pinUrl": "https://www.aok.de/pk/plus/inhalt/elektronische-gesundheitskarte-11/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK Rheinland/Hamburg - Die Gesundheitskasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "aok@rh.aok.de", + "healthCardAndPinUrl": null, + "pinUrl": "https://www.aok.de/pk/rh/inhalt/pin-zur-elektronischen-gesundheitskarte-egk-5/", + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK Rheinland-Pfalz/Saarland - Die Gesundheitskasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.aok.de/pk/rps/inhalt/die-haeufigsten-fragen-und-antworten-zum-e-rezept-4/", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "AOK Sachsen-Anhalt - Die Gesundheitskasse", + "healthCardAndPinPhone": "+4908002265725", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Audi BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.audibkk.de/e-rezept-gematik-egkpin/", + "pinUrl": "https://www.audibkk.de/e-rezept-gematik-pin/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Deutsche Bahn AG", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bahn-bkk.de/egk-erezept", + "pinUrl": "https://www.bahn-bkk.de/egk-erezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Deutsche Bank AG", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkkdb.de/leistungen-beratung/alle-leistungen/alle-leistungen-von-a-z/versichertenkarte", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BARMER", + "healthCardAndPinPhone": "+498003331010", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.barmer.de/gematik-eRezept", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "DIE BERGISCHE KRANKENKASSE", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bergische-krankenkasse.de/digital/e-rezept", + "pinUrl": "https://www.bergische-krankenkasse.de/digital/e-rezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Bertelsmann BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bertelsmann-bkk.de/erezept-egk-pin", + "pinUrl": "https://www.bertelsmann-bkk.de/erezept-egk-pin", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BIG direkt gesund", + "healthCardAndPinPhone": "+4980054565456", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.big-direkt.de/de/erezept-der-gematik-nutzen", + "pinUrl": "https://www.big-direkt.de/de/erezept-der-gematik-nutzen", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Akzo Nobel Bayern", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-akzo.de/service/elektronische-gesundheitskarte-egk", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK B. Braun Aesculap", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-bba.de/egk-pin-bestellen", + "pinUrl": "https://www.bkk-bba.de/pin-bestellen", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK BPW Bergische Achsen KG", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK EUREGIO", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-euregio.de/elektronische-gesundheitskarte", + "pinUrl": "https://www.bkk-euregio.de/elektronische-gesundheitskarte", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK EVM", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK EWE", + "healthCardAndPinPhone": "+49441350285108", + "healthCardAndPinMail": "versicherung@bkk-ewe.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Diakonie", + "healthCardAndPinPhone": "+49521329876120", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-diakonie.de/elektronische-gesundheitskarte/egk-bestellen/", + "pinUrl": "https://www.bkk-diakonie.de/elektronische-gesundheitskarte/egk-bestellen/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK exklusiv", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://bkkexklusiv.de/gesundheitskarte", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Faber-Castell & Partner", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "erezept@bkk-faber-castell.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK firmus", + "healthCardAndPinPhone": "+4942164343", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-firmus.de/beratung-und-service/online-tools/e-rezept.html", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Freudenberg", + "healthCardAndPinPhone": "+4962016905001", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK GILDEMEISTER SEIDENSTICKER", + "healthCardAndPinPhone": "+498000255255", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkkgs.de/e-rezept", + "pinUrl": "https://www.bkkgs.de/e-rezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK GRILLO-WERKE AG", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Groz-Beckert", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "info@bkk-gb.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Herford Minden Ravensberg", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Herkules", + "healthCardAndPinPhone": "+49561208550", + "healthCardAndPinMail": "info@bkk-herkules.de", + "healthCardAndPinUrl": "https://www.bkk-herkules.de/service/gesundheitskarte-und-lichtbild/", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK HMR", + "healthCardAndPinPhone": "+4952211026210", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK KARL MAYER", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Linde", + "healthCardAndPinPhone": "+496117366781", + "healthCardAndPinMail": "egk@bkk-linde.de", + "healthCardAndPinUrl": "https://bkkln.de/epa-egkpin", + "pinUrl": "https://bkkln.de/epa-egkpin", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Melitta HMR", + "healthCardAndPinPhone": "+4957197590", + "healthCardAndPinMail": "info@bkk-melitta.de", + "healthCardAndPinUrl": "https://www.bkk-melitta.de/", + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK MAHLE", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-mahle.de/service/elektronische-gesundheitskarte-egk", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Miele", + "healthCardAndPinPhone": "+498008002189", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.miele-bkk.de/service/elektronische-gesundheitskarte", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK MTU", + "healthCardAndPinPhone": "+497541907100", + "healthCardAndPinMail": "info@bkk-mtu.de", + "healthCardAndPinUrl": "https://www.bkk-mtu.de/unsere-leistungen/leistungen-a-z/elektronische-gesundheitskarte-egk-bkk-mtu-service/", + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK PFAFF", + "healthCardAndPinPhone": "+49631318760", + "healthCardAndPinMail": "info@bkk-pfaff.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Pfalz", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkkpfalz.de/service-informationen/elektronische-gesundheitskarte", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK ProVita", + "healthCardAndPinPhone": "+498006648808", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://bkk-provita.de/service-info/e-rezept/", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Public", + "healthCardAndPinPhone": "+495341405600", + "healthCardAndPinMail": "service@bkk-public.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK PricewaterhouseCoopers", + "healthCardAndPinPhone": "+498002557920", + "healthCardAndPinMail": "erezept@bkk-pwc.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK Rieker RICOSTA Weisser", + "healthCardAndPinPhone": "+4974625793030", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK RWE", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkkrwe.de/e-rezept", + "pinUrl": "https://www.bkkrwe.de/e-rezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Salzgitter", + "healthCardAndPinPhone": "+495341405700", + "healthCardAndPinMail": "service@bkk-salzgitter.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK SBH", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-sbh.de/e-rezept/", + "pinUrl": "https://bkk-sbh.de/e-rezept/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Scheufelen", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-scheufelen.de/e-rezept", + "pinUrl": "https://www.bkk-scheufelen.de/e-rezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Schwarzwald-BaarHeuberg", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK STADT AUGSBURG", + "healthCardAndPinPhone": "+498213243231", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Technoform", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-technoform.de/index.php?p=page&ID=11", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Textilgruppe Hof", + "healthCardAndPinPhone": "+498002558440", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Verkehrsbau Union (VBU)", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "info@bkk-vbu.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK VDN", + "healthCardAndPinPhone": "+49230498260", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK VerbundPlus", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-verbundplus.de/ihre-mitgliedschaft/elektronische-gesundheitskarte/", + "pinUrl": "https://www.bkk-verbundplus.de/nfc-karte-pin", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Voralb", + "healthCardAndPinPhone": "+4970229324639", + "healthCardAndPinMail": "beitraege@bkk-voralb.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK Werra-Meissner", + "healthCardAndPinPhone": "+490565174510", + "healthCardAndPinMail": "info@bkk-wm.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK Wirtschaft & Finanzen", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk-wf.de/e-rezept/", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK Würth", + "healthCardAndPinPhone": "+49794091900", + "healthCardAndPinMail": "info@bkk-wuerth.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "BKK ZF & Partner", + "healthCardAndPinPhone": "+493381306652512", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK_DürkoppAdler", + "healthCardAndPinPhone": "+495215578470", + "healthCardAndPinMail": "eRezept@bkk-da.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BKK24", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bkk24.de/e-rezept", + "pinUrl": "https://bkk24.de/e-rezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "BMW BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.bmwbkk.de/egk", + "pinUrl": "https://www.bmwbkk.de/egk-pin-puk", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Bosch BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "info@bosch-bkk.de", + "healthCardAndPinUrl": "https://meine.bosch-bkk.de/bitgo_gs/de/oeffentlich/login/login.xhtml", + "pinUrl": "https://meine.bosch-bkk.de/bitgo_gs/de/oeffentlich/login/login.xhtml", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Continentale BKK", + "healthCardAndPinPhone": "+498006262626", + "healthCardAndPinMail": "kundenservice@continentale-bkk.de", + "healthCardAndPinUrl": "https://www.continentale-bkk.de/kontakt/kontaktformular/", + "pinUrl": "https://www.continentale-bkk.de/kontakt/kontaktformular/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Daimler BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.daimler-bkk.com/service/erezept", + "pinUrl": "https://www.daimler-bkk.com/service/erezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "DAK-Gesundheit", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.dak.de/dak/ihr-anliegen/elektronische-gesundheitskarte-2083662.html#/", + "pinUrl": "https://www.dak.de/dak/ihr-anliegen/elektronische-gesundheitskarte-2083662.html#/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Debeka BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": "https://www.debeka-bkk.de/erezept/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "energie - Betriebskrankenkasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Ernst & Young BKK", + "healthCardAndPinPhone": "+495661707670", + "healthCardAndPinMail": "versicherung@ey-bkk.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "Heimat Krankenkasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.heimat-krankenkasse.de/egk-anfordern", + "pinUrl": "https://www.heimat-krankenkasse.de/egk-pin-anfordern", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "HEK - Hanseatische Krankenkasse", + "healthCardAndPinPhone": "+498000213213", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": "https://www.hek.de/egk", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Handelskrankenkasse (hkk)", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.hkk.de/versicherung-und-tarife/allgemeine-infos/erezept-app", + "pinUrl": "https://www.hkk.de/versicherung-und-tarife/allgemeine-infos/erezept-app", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "IKK - Die Innovationskasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.die-ik.de/e-rezept", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "IKK Brandenburg und Berlin", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.ikkbb.de/erezept/auth-egk", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "IKK classic", + "healthCardAndPinPhone": "+498004551111", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "IKK gesund plus", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "IKK Südwest", + "healthCardAndPinPhone": "+498000119119", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.ikk-suedwest.de/service/persoenlicher-kundenberater/", + "pinUrl": "https://www.ikk-suedwest.de/service/persoenlicher-kundenberater/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Kaufmännische Krankenkasse - KKH", + "healthCardAndPinPhone": "+498005548640554", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "KNAPPSCHAFT", + "healthCardAndPinPhone": "+498000200501", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Koenig & Bauer BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "erezept@koenig-bauer-bkk.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "Krones BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "Merck BKK", + "healthCardAndPinPhone": "+496151722256", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "mhplus Betriebskrankenkasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.mhplus-krankenkasse.de/privatkunden/unser-service/services-fuer-mitglieder/mhplus-gesundheitskarte-bestellen", + "pinUrl": "https://www.mhplus-krankenkasse.de/privatkunden/unser-service/gesundheit-digital/elektronische-patientenakte/registrierung-fuer-digitale-anwendungen", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Mobil Krankenkasse", + "healthCardAndPinPhone": "+498002550800", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://mobil-krankenkasse.de/unser-service/elektronische-gesundheitskarte/pin-puk-egk.html", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Novitas BKK", + "healthCardAndPinPhone": "+498006566300", + "healthCardAndPinMail": "gesundheitskarte@novitas-bkk.de", + "healthCardAndPinUrl": "https://www.novitas-bkk.de/egk", + "pinUrl": "https://www.novitas-bkk.de/egk", + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "pronova BKK", + "healthCardAndPinPhone": "+49621533911000", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.pronovabkk.de/leistungen/elektronische-gesundheitskarte", + "pinUrl": "https://meine.pronovabkk.de/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "R+V Betriebskrankenkasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.ruv-bkk.de/leistungen/alle-leistungen-im-ueberblick/leistungen-a-z/e/erezept/", + "pinUrl": "https://www.ruv-bkk.de/leistungen/alle-leistungen-im-ueberblick/leistungen-a-z/e/erezept/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Salus BKK", + "healthCardAndPinPhone": "+498002213222", + "healthCardAndPinMail": "egk@salus-bkk.de", + "healthCardAndPinUrl": "https://www.salus-bkk.de/service-formulare/infos-zur-mitgliedschaft/meine-gesundheitskarte/elektronische-gesundheitskarte/", + "pinUrl": "https://www.salus-bkk.de/service-formulare/infos-zur-mitgliedschaft/meine-gesundheitskarte/elektronische-gesundheitskarte/", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "SIEMAG BKK", + "healthCardAndPinPhone": "+492733292929", + "healthCardAndPinMail": "info@siemagbkk.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Siemens-Betriebskrankenkasse (SBK)", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://meine.sbk.org/pin_gesundheitskarte", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "SECURVITA BKK", + "healthCardAndPinPhone": "+494033477", + "healthCardAndPinMail": "egk@securvita-bkk.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "SKD BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.skd-bkk.de/leistungen/26-elektronische-gesundheitskarte-egk/", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Südzucker BKK", + "healthCardAndPinPhone": "+496213285845", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Sozialversicherung für Landwirtschaft, Forsten und Gartenbau (SVLFG)", + "healthCardAndPinPhone": "+495617850", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://portal.svlfg.de/svlfg-apps/gesundheitskarte", + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "Techniker Krankenkasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.tk.de/techniker/2113848", + "pinUrl": "https://www.tk.de/techniker/2113852", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "TUI BKK", + "healthCardAndPinPhone": "+495341405800", + "healthCardAndPinMail": "service@tui-bkk.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "VIACTIV BKK", + "healthCardAndPinPhone": "+498002221211", + "healthCardAndPinMail": "service@viactiv.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "vivida bkk", + "healthCardAndPinPhone": "+49800375537555", + "healthCardAndPinMail": "kundencenter@vividabkk.de", + "healthCardAndPinUrl": "https://www.vividabkk.de/de/service/e-rezept-info", + "pinUrl": "https://www.vividabkk.de/de/service/e-rezept-info", + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + }, + { + "name": "Wieland BKK", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": "https://www.wieland-bkk.de/service/unsere-digitalen-moeglichkeiten/e-rezept", + "pinUrl": "https://www.wieland-bkk.de/service/unsere-digitalen-moeglichkeiten/e-rezept", + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null + }, + { + "name": "WMF Betriebskrankenkasse", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": "service@wmf-bkk.de", + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": "#eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte und PIN", + "bodyCardAndPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nBitte senden Sie mir hierfür eine NFC-fähige Gesundheitskarte zu. Ich benötige zu der Gesundheitskarte auch die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte", + "subjectPinMail": "#PIN# Bestellung einer PIN zur Gesundheitskarte", + "bodyPinMail": "Sehr geehrte Damen und Herren,\nich möchte die E-Rezept-App der gematik nutzen.\n\nName:\nVorname:\nVersichertennummer/ KVNR:\n\nIch benötige zu der Gesundheitskarte die PIN. Bitte senden Sie mir Informationen zu, wie ich die PIN erhalten kann.\n\nMit freundlichen Grüßen\nIhr Versicherter/ Ihre Versicherte" + } +] \ No newline at end of file diff --git a/android/src/main/res/raw/healthcard_lottie.json b/android/src/main/res/raw/healthcard_lottie.json new file mode 100644 index 00000000..f4391347 --- /dev/null +++ b/android/src/main/res/raw/healthcard_lottie.json @@ -0,0 +1 @@ +{"v":"5.6.6","ip":0,"op":1,"fr":60,"w":172,"h":168,"layers":[{"ind":1426,"nm":"surface4347","ao":0,"ip":0,"op":60,"st":0,"ty":4,"ks":{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[133.33,133.33]},"sk":{"k":0},"sa":{"k":0}},"shapes":[{"ty":"gr","hd":false,"nm":"surface4347","it":[{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[-1.88,-1.02],[0,0],[1.99,-1.2],[0,0],[1.89,1.09],[0,0],[-1.94,1.19]],"o":[[1.82,-1.12],[0,0],[2.05,1.11],[0,0],[-1.88,1.12],[0,0],[-1.96,-1.14],[0,0]],"v":[[48.59,29.29],[54.58,29.13],[121.92,65.54],[122.04,70.75],[76.98,97.8],[70.88,97.85],[6.3,60.38],[6.23,55.23]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.56,0.8,0.96,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"gr","hd":false,"it":[{"ty":"sh","ks":{"k":{"i":[[0,0],[-0.94,-0.51],[0,0],[1.99,-1.2],[0,0],[1.89,1.09],[0,0],[-1.94,1.19]],"o":[[0.91,-0.56],[0,0],[2.05,1.11],[0,0],[-1.87,1.12],[0,0],[-1.96,-1.14],[0,0]],"v":[[50.07,29.14],[53.06,29.05],[121.92,66.29],[122.04,71.5],[76.98,98.55],[70.88,98.6],[6.3,61.13],[6.24,55.98]],"c":true}}},{"ty":"fl","o":{"k":100},"c":{"k":[0.96,0.96,0.96,1]}},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]},{"ty":"tr","o":{"k":100},"r":{"k":0},"p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"sk":{"k":0},"sa":{"k":0},"hd":false}]}]}],"meta":{"g":"LF SVG to Lottie"}} \ No newline at end of file diff --git a/android/src/main/res/raw/nfc_positions.json b/android/src/main/res/raw/nfc_positions.json new file mode 100644 index 00000000..c3059a02 --- /dev/null +++ b/android/src/main/res/raw/nfc_positions.json @@ -0,0 +1,1290 @@ +[ + { + "marketingName": "HONOR 10", + "modelNames": [ + "COL-AL00", + "COL-AL10", + "COL-L29", + "COL-TL10" + ], + "x0": 0.07851239669421484, + "y0": 0.011210762331838564, + "x1": 0.5165289256198347, + "y1": 0.09865470852017937 + }, + { + "marketingName": "HONOR 20", + "modelNames": [ + "YAL-AL00", + "YAL-L21", + "YAL-TL00" + ], + "x0": 0.265625, + "y0": 0.014084507042253521, + "x1": 0.7135416666666667, + "y1": 0.15023474178403756 + }, + { + "marketingName": "HONOR 9", + "modelNames": [ + "STF-AL00", + "STF-AL10", + "STF-L09", + "STF-L09S", + "STF-TL10" + ], + "x0": 0.0, + "y0": 0.0, + "x1": 0.4530386740331491, + "y1": 0.08735632183908046 + }, + { + "marketingName": "HONOR Magic 2", + "modelNames": [ + "TNY-AL00", + "TNY-TL00" + ], + "x0": 0.0, + "y0": 0.0, + "x1": 0.6772486772486772, + "y1": 0.10304449648711944 + }, + { + "marketingName": "HONOR Note10", + "modelNames": [ + "RVL-AL09" + ], + "x0": 0.17803030303030298, + "y0": 0.0044943820224719105, + "x1": 0.7992424242424243, + "y1": 0.056179775280898875 + }, + { + "marketingName": "HONOR Play", + "modelNames": [ + "COR-AL00", + "COR-AL10", + "COR-L29", + "COR-TL10" + ], + "x0": 0.042857142857142816, + "y0": 0.020179372197309416, + "x1": 0.6761904761904762, + "y1": 0.12556053811659193 + }, + { + "marketingName": "HONOR V10", + "modelNames": [ + "BKL-AL00", + "BKL-AL20", + "BKL-TL10" + ], + "x0": 0.07851239669421484, + "y0": 0.011210762331838564, + "x1": 0.5165289256198347, + "y1": 0.09865470852017937 + }, + { + "marketingName": "HONOR V20", + "modelNames": [ + "PCT-TL10" + ], + "x0": 0.3121693121693122, + "y0": 0.07621247113163972, + "x1": 0.5873015873015873, + "y1": 0.19630484988452657 + }, + { + "marketingName": "HONOR V9", + "modelNames": [ + "DUK-AL20", + "DUK-TL30" + ], + "x0": 0.0, + "y0": 0.0, + "x1": 0.4530386740331491, + "y1": 0.08735632183908046 + }, + { + "marketingName": "HUAWEI Mate 10-Serie", + "modelNames": [ + "ALP-AL00", + "ALP-L09", + "ALP-L29", + "ALP-TL00", + "BLA-A09", + "BLA-AL00", + "BLA-L09", + "BLA-L29", + "BLA-TL00", + "RNE-L01", + "RNE-L03", + "RNE-L21", + "RNE-L23" + ], + "x0": 0.1701030927835051, + "y0": 0.0, + "x1": 0.7731958762886598, + "y1": 0.12413793103448276 + }, + { + "marketingName": "HUAWEI Mate 20-Serie", + "modelNames": [ + "HMA-L09", + "HMA-L29", + "LYA-L0C", + "LYA-L29", + "EVR-AN00", + "EVR-N29", + "SNE-LX1", + "EVR-L29", + "EVR-TL00", + "EVR-N29", + "EVR-AL00", + "HMA-AL00", + "HMA-L09", + "HMA-L29", + "HMA-TL00", + "LYA-AL00", + "LYA-AL10", + "LYA-L09", + "LYA-L29", + "LYA-TL00", + "LYA-AL00P", + "SNE-LX1", + "SNE-LX2", + "SNE-LX3" + ], + "x0": 0.2328042328042328, + "y0": 0.10161662817551963, + "x1": 0.7671957671957672, + "y1": 0.2678983833718245 + }, + { + "marketingName": "HUAWEI Mate 30-Serie", + "modelNames": [], + "x0": 0.12903225806451613, + "y0": 0.020833333333333332, + "x1": 0.8333333333333334, + "y1": 0.3055555555555556 + }, + { + "marketingName": "HUAWEI Mate 40-Serie", + "modelNames": [], + "x0": 0.08860759493670889, + "y0": 0.012587412587412588, + "x1": 0.9113924050632911, + "y1": 0.35664335664335667 + }, + { + "marketingName": "HUAWEI Mate 9-Serie", + "modelNames": [ + "BLL-L23", + "HUAWEI BLL-L23", + "MHA-AL00", + "MHA-L09", + "MHA-L29", + "MHA-TL00", + "LON-AL00", + "LON-L29" + ], + "x0": 0.17431192660550454, + "y0": 0.0022935779816513763, + "x1": 0.8027522935779816, + "y1": 0.06422018348623854 + }, + { + "marketingName": "HUAWEI Mate RS", + "modelNames": [ + "NEO-AL00", + "NEO-L29" + ], + "x0": 0.13440860215053763, + "y0": 0.02947845804988662, + "x1": 0.8763440860215054, + "y1": 0.1836734693877551 + }, + { + "marketingName": "HUAWEI Mate X-Serie", + "modelNames": [], + "x0": 0.023400936037441533, + "y0": 0.0, + "x1": 0.37597503900156004, + "y1": 0.04741980474198047 + }, + { + "marketingName": "HUAWEI nova 2s", + "modelNames": [ + "HWI-AL00", + "HWI-TL00" + ], + "x0": 0.07106598984771573, + "y0": 0.0022988505747126436, + "x1": 0.5076142131979695, + "y1": 0.12413793103448276 + }, + { + "marketingName": "HUAWEI nova 3-Serie", + "modelNames": [ + "INE-LX1", + "INE-LX2r", + "PAR-AL00", + "PAR-L21", + "PAR-L29", + "PAR-LX1", + "PAR-LX1M", + "PAR-LX9", + "PAR-TL00", + "PAR-TL20", + "ANE-AL00", + "ANE-TL00", + "INE-AL00", + "INE-LX1", + "INE-LX1r", + "INE-LX2", + "INE-TL00" + ], + "x0": 0.0, + "y0": 0.0, + "x1": 0.7679558011049724, + "y1": 0.0979020979020979 + }, + { + "marketingName": "HUAWEI P10-Serie", + "modelNames": [ + "VTR-AL00", + "VTR-L09", + "VTR-L29", + "VTR-TL00", + "VKY-AL00", + "VKY-L09", + "VKY-L29", + "VKY-TL00", + "WAS-L03T", + "WAS-LX1", + "WAS-LX1A", + "WAS-LX2", + "WAS-LX2J", + "WAS-LX3" + ], + "x0": 0.1707317073170732, + "y0": 0.0, + "x1": 0.5951219512195122, + "y1": 0.07209302325581396 + }, + { + "marketingName": "HUAWEI P20-Serie", + "modelNames": [ + "ANE-LX2J", + "HWV32", + "EML-AL00", + "EML-L09", + "EML-L29", + "EML-TL00", + "HW-01K", + "CLT-AL00", + "CLT-AL00l", + "CLT-AL01", + "CLT-L04", + "CLT-L09", + "CLT-L29", + "CLT-TL00", + "CLT-TL01", + "ANE-LX1", + "ANE-LX2", + "ANE-LX3", + "CLT-L09", + "CLT-L29" + ], + "x0": 0.0871794871794872, + "y0": 0.02546296296296296, + "x1": 0.7128205128205128, + "y1": 0.2708333333333333 + }, + { + "marketingName": "HUAWEI P30-Serie", + "modelNames": [ + "ELE-AL00", + "ELE-L04", + "ELE-L09", + "ELE-L14", + "ELE-L29", + "ELE-L39", + "ELE-L49", + "ELE-TL00", + "HWV33", + "MAR-LX1A", + "MAR-LX1Am", + "MAR-LX1B", + "MAR-LX1M", + "MAR-LX1Mm", + "MAR-LX2", + "MAR-LX2B", + "MAR-LX2m", + "MAR-LX3A", + "MAR-LX3Am", + "MAR-LX3Bm", + "ELE-L09", + "HW-02L", + "VOG-AL00", + "VOG-AL10", + "VOG-L04", + "VOG-L09", + "VOG-L29", + "VOG-TL00", + "MAR-LX2J" + ], + "x0": 0.07853403141361259, + "y0": 0.020737327188940093, + "x1": 0.6073298429319371, + "y1": 0.2557603686635945 + }, + { + "marketingName": "HUAWEI P40-Serie", + "modelNames": [ + "ANA-AN00", + "ANA-TN00", + "ANA-NX9", + "ANA-LX4" + ], + "x0": 0.032573289902280145, + "y0": 0.04495912806539509, + "x1": 0.501628664495114, + "y1": 0.3201634877384196 + }, + { + "marketingName": "Samsung Galaxy A32 5G", + "modelNames": [ + "SCG08", + "SM-A326B", + "SM-A326BR", + "SM-A326U", + "SM-A326U1", + "SM-A326W", + "SM-S326DL" + ], + "x0": 0.0699300699300699, + "y0": 0.29354838709677417, + "x1": 0.8951048951048951, + "y1": 0.603225806451613 + }, + { + "marketingName": "Samsung Galaxy A42 5G", + "modelNames": [ + "SM-A4260", + "SM-A426B", + "SM-A426N", + "SM-A426U", + "SM-A426U1", + "SM-S426DL" + ], + "x0": 0.049645390070921946, + "y0": 0.26129032258064516, + "x1": 0.9290780141843972, + "y1": 0.6903225806451613 + }, + { + "marketingName": "Samsung Galaxy A50s", + "modelNames": [ + "SM-A5070", + "SM-A507FN" + ], + "x0": 0.217687074829932, + "y0": 0.1064516129032258, + "x1": 0.7006802721088435, + "y1": 0.3064516129032258 + }, + { + "marketingName": "Samsung Galaxy A51", + "modelNames": [ + "SM-A515F", + "SM-A515U", + "SM-A515U1", + "SM-A515W", + "SM-S515DL" + ], + "x0": 0.08450704225352113, + "y0": 0.08108108108108109, + "x1": 0.6056338028169015, + "y1": 0.2905405405405405 + }, + { + "marketingName": "Samsung Galaxy A52 5G", + "modelNames": [ + "SC-53B", + "SM-A5260", + "SM-A526B", + "SM-A526N", + "SM-A526U", + "SM-A526U1", + "SM-A526W" + ], + "x0": 0.03597122302158273, + "y0": 0.0, + "x1": 0.5611510791366907, + "y1": 0.28378378378378377 + }, + { + "marketingName": "Samsung Galaxy A60", + "modelNames": [ + "SM-A6060", + "SM-A606Y" + ], + "x0": 0.1842105263157895, + "y0": 0.13306451612903225, + "x1": 0.7456140350877193, + "y1": 0.3225806451612903 + }, + { + "marketingName": "Samsung Galaxy A70", + "modelNames": [ + "SM-A7050", + "SM-A705F", + "SM-A705FN", + "SM-A705GM", + "SM-A705MN", + "SM-A705U", + "SM-A705W", + "SM-A705YN" + ], + "x0": 0.2206896551724138, + "y0": 0.12903225806451613, + "x1": 0.7655172413793103, + "y1": 0.3064516129032258 + }, + { + "marketingName": "Samsung Galaxy A71", + "modelNames": [ + "SM-A715F", + "SM-A715W" + ], + "x0": 0.07638888888888884, + "y0": 0.050335570469798654, + "x1": 0.5972222222222222, + "y1": 0.2684563758389262 + }, + { + "marketingName": "Samsung Galaxy A8+", + "modelNames": [], + "x0": 0.1095890410958904, + "y0": 0.3076923076923077, + "x1": 0.8561643835616438, + "y1": 0.5737179487179487 + }, + { + "marketingName": "Samsung Galaxy A8", + "modelNames": [ + "SCV32", + "SM-A800F", + "SM-A800YZ", + "SM-A800S", + "SM-A800I", + "SM-A800IZ", + "SM-A8000", + "SM-A800X" + ], + "x0": 0.19424460431654678, + "y0": 0.06752411575562701, + "x1": 0.7769784172661871, + "y1": 0.21221864951768488 + }, + { + "marketingName": "Samsung Galaxy A80", + "modelNames": [ + "SM-A8050", + "SM-A805F", + "SM-A805N" + ], + "x0": 0.13013698630136983, + "y0": 0.18387096774193548, + "x1": 0.8356164383561644, + "y1": 0.47419354838709676 + }, + { + "marketingName": "Samsung Galaxy A8s", + "modelNames": [ + "SM-G887F", + "SM-G8870" + ], + "x0": 0.25, + "y0": 0.10240963855421686, + "x1": 0.7714285714285715, + "y1": 0.3072289156626506 + }, + { + "marketingName": "Samsung Galaxy A9 (2018)", + "modelNames": [ + "SM-A920F", + "SM-A920N" + ], + "x0": 0.04402515723270439, + "y0": 0.03115264797507788, + "x1": 0.9559748427672956, + "y1": 0.4143302180685358 + }, + { + "marketingName": "Samsung Galaxy C5 Pro", + "modelNames": [ + "SM-C5010", + "SM-C5018" + ], + "x0": 0.23076923076923073, + "y0": 0.08012820512820513, + "x1": 0.6474358974358974, + "y1": 0.2403846153846154 + }, + { + "marketingName": "Samsung Galaxy C7 Pro", + "modelNames": [ + "SM-C701F", + "SM-C7010", + "SM-C7018" + ], + "x0": 0.27338129496402874, + "y0": 0.0641025641025641, + "x1": 0.7122302158273381, + "y1": 0.21794871794871795 + }, + { + "marketingName": "Samsung Galaxy C9 Pro", + "modelNames": [ + "SM-C900F", + "SM-C900Y", + "SM-C9000", + "SM-C9008", + "SM-C900X" + ], + "x0": 0.22857142857142854, + "y0": 0.07333333333333333, + "x1": 0.6928571428571428, + "y1": 0.20333333333333334 + }, + { + "marketingName": "Samsung Galaxy Fold", + "modelNames": [ + "SCV44", + "SM-F9000", + "SM-F900F", + "SM-F900U", + "SM-F900U1", + "SM-F900W" + ], + "x0": 0.15315315315315314, + "y0": 0.38387096774193546, + "x1": 0.9099099099099099, + "y1": 0.6387096774193548 + }, + { + "marketingName": "Samsung Galaxy Note10 Lite", + "modelNames": [ + "SM-N770F" + ], + "x0": 0.10416666666666663, + "y0": 0.08389261744966443, + "x1": 0.5555555555555556, + "y1": 0.2953020134228188 + }, + { + "marketingName": "Samsung Galaxy Note10+", + "modelNames": [ + "SC-01M", + "SCV45", + "SM-N9750", + "SM-N975C", + "SM-N975U", + "SM-N975U1", + "SM-N975W", + "SM-N975F" + ], + "x0": 0.13157894736842102, + "y0": 0.06129032258064516, + "x1": 0.7171052631578947, + "y1": 0.35161290322580646 + }, + { + "marketingName": "Samsung Galaxy Note10", + "modelNames": [ + "SM-N970F", + "SM-N9700", + "SM-N970U", + "SM-N970U1", + "SM-N970W" + ], + "x0": 0.11538461538461542, + "y0": 0.06129032258064516, + "x1": 0.6923076923076923, + "y1": 0.3419354838709677 + }, + { + "marketingName": "Samsung Galaxy Note20 5G", + "modelNames": [ + "SM-N9810", + "SM-N981N", + "SM-N981U", + "SM-N981U1", + "SM-N981W", + "SM-N981B" + ], + "x0": 0.10516252390057357, + "y0": 0.4105263157894737, + "x1": 0.9082217973231358, + "y1": 0.7140350877192982 + }, + { + "marketingName": "Samsung Galaxy Note20 Ultra 5G", + "modelNames": [ + "SC-53A", + "SCG06", + "SM-N9860", + "SM-N986N", + "SM-N986U", + "SM-N986U1", + "SM-N986W", + "SM-N986B" + ], + "x0": 0.06691449814126393, + "y0": 0.34509466437177283, + "x1": 0.9219330855018587, + "y1": 0.6858864027538726 + }, + { + "marketingName": "Samsung Galaxy Note5", + "modelNames": [ + "SM-N9208", + "SM-N920C", + "SM-N920F", + "SM-N920G", + "SM-N920I", + "SM-N920X", + "SM-N920R7", + "SAMSUNG-SM-N920A", + "SM-N920W8", + "SM-N9200", + "SM-N9208", + "SM-N9200", + "SM-N920K", + "SM-N920L", + "SM-N920R6", + "SM-N920S", + "SM-N920P", + "SM-N920T", + "SM-N920R4", + "SM-N920V" + ], + "x0": 0.09219858156028371, + "y0": 0.4180064308681672, + "x1": 0.9787234042553191, + "y1": 0.77491961414791 + }, + { + "marketingName": "Samsung Galaxy Note8", + "modelNames": [ + "SC-01K", + "SCV37", + "SM-N950F", + "SM-N950N", + "SM-N950XN", + "SM-N950U", + "SM-N9500", + "SM-N9508", + "SM-N950W", + "SM-N950U1" + ], + "x0": 0.18055555555555558, + "y0": 0.24666666666666667, + "x1": 0.8402777777777778, + "y1": 0.6266666666666667 + }, + { + "marketingName": "Samsung Galaxy Note9", + "modelNames": [ + "SC-01L", + "SCV40", + "SM-N960F", + "SM-N960N", + "SM-N9600", + "SM-N960W", + "SM-N960U", + "SM-N960U1" + ], + "x0": 0.23239436619718312, + "y0": 0.3389261744966443, + "x1": 0.823943661971831, + "y1": 0.5906040268456376 + }, + { + "marketingName": "Samsung Galaxy S10+", + "modelNames": [ + "SC-04L", + "SCV42", + "SM-G975F", + "SM-G975N", + "SM-G9750", + "SM-G9758", + "SM-G975U", + "SM-G975U1", + "SM-G975W" + ], + "x0": 0.1806451612903226, + "y0": 0.3383233532934132, + "x1": 0.8129032258064516, + "y1": 0.6347305389221557 + }, + { + "marketingName": "Samsung Galaxy S10", + "modelNames": [ + "SC-03L", + "SCV41", + "SM-G973F", + "SM-G973N", + "SM-G9730", + "SM-G9738", + "SM-G973C", + "SM-G973U", + "SM-G973U1", + "SM-G973W" + ], + "x0": 0.05031446540880502, + "y0": 0.2433234421364985, + "x1": 0.8050314465408805, + "y1": 0.6468842729970327 + }, + { + "marketingName": "Samsung Galaxy S10e", + "modelNames": [ + "SM-G970F", + "SM-G970N", + "SM-G9700", + "SM-G9708", + "SM-G970U", + "SM-G970U1", + "SM-G970W" + ], + "x0": 0.20370370370370372, + "y0": 0.322884012539185, + "x1": 0.8024691358024691, + "y1": 0.5987460815047022 + }, + { + "marketingName": "Samsung Galaxy S20 FE", + "modelNames": [ + "SM-G780G", + "SM-G780F" + ], + "x0": 0.0680628272251309, + "y0": 0.36764705882352944, + "x1": 0.9267015706806283, + "y1": 0.7271241830065359 + }, + { + "marketingName": "Samsung Galaxy S20 Ultra", + "modelNames": [], + "x0": 0.0680628272251309, + "y0": 0.36764705882352944, + "x1": 0.9267015706806283, + "y1": 0.7271241830065359 + }, + { + "marketingName": "Samsung Galaxy S20+", + "modelNames": [ + "SM-G985F" + ], + "x0": 0.0680628272251309, + "y0": 0.36764705882352944, + "x1": 0.9267015706806283, + "y1": 0.7271241830065359 + }, + { + "marketingName": "Samsung Galaxy S20", + "modelNames": [ + "SM-G980F" + ], + "x0": 0.0680628272251309, + "y0": 0.36764705882352944, + "x1": 0.9267015706806283, + "y1": 0.7271241830065359 + }, + { + "marketingName": "Samsung Galaxy S21 5G", + "modelNames": [ + "SC-51B", + "SCG09", + "SM-G9910", + "SM-G991Q", + "SM-G991U1", + "SM-G991W", + "SM-G991B", + "SM-G991N" + ], + "x0": 0.04929577464788737, + "y0": 0.46308724832214765, + "x1": 0.9436619718309859, + "y1": 0.7651006711409396 + }, + { + "marketingName": "Samsung Galaxy S21 FE 5G", + "modelNames": [ + "SM-G9900", + "SM-G990B", + "SM-G990U", + "SM-G990U1", + "SM-G990W", + "SM-G990E" + ], + "x0": 0.08904109589041098, + "y0": 0.28619528619528617, + "x1": 0.9178082191780822, + "y1": 0.6531986531986532 + }, + { + "marketingName": "Samsung Galaxy S21 Ultra 5G", + "modelNames": [ + "SC-52B", + "SM-G9980", + "SM-G998U", + "SM-G998U1", + "SM-G998W", + "SM-G998B", + "SM-G998N" + ], + "x0": 0.02877697841726623, + "y0": 0.40604026845637586, + "x1": 0.9568345323741008, + "y1": 0.7550335570469798 + }, + { + "marketingName": "Samsung Galaxy S21+ 5G", + "modelNames": [ + "SCG10", + "SM-G9960", + "SM-G996U1", + "SM-G996W", + "SM-G996B", + "SM-G996N" + ], + "x0": 0.035211267605633756, + "y0": 0.39932885906040266, + "x1": 0.971830985915493, + "y1": 0.7550335570469798 + }, + { + "marketingName": "Samsung Galaxy S22 Ultra", + "modelNames": [ + "SC-52C", + "SCG14", + "SM-S9080", + "SM-S908E", + "SM-S908N", + "SM-S908U", + "SM-S908U1", + "SM-S908W", + "SM-S908B" + ], + "x0": 0.007633587786259555, + "y0": 0.34838709677419355, + "x1": 1.0, + "y1": 0.7741935483870968 + }, + { + "marketingName": "Samsung Galaxy S22+", + "modelNames": [ + "SM-S9060", + "SM-S906E", + "SM-S906N", + "SM-S906U", + "SM-S906U1", + "SM-S906W", + "SM-S906B" + ], + "x0": 0.05405405405405406, + "y0": 0.3258064516129032, + "x1": 0.9594594594594594, + "y1": 0.7548387096774194 + }, + { + "marketingName": "Samsung Galaxy S22", + "modelNames": [ + "SC-51C", + "SCG13", + "SM-S9010", + "SM-S901E", + "SM-S901N", + "SM-S901U", + "SM-S901U1", + "SM-S901W", + "SM-S901B" + ], + "x0": 0.05921052631578949, + "y0": 0.35161290322580646, + "x1": 0.9144736842105263, + "y1": 0.7580645161290323 + }, + { + "marketingName": "Samsung Galaxy S6 edge+", + "modelNames": [ + "SM-G9287", + "SM-G928F", + "SM-G928G" + ], + "x0": 0.27922077922077926, + "y0": 0.36012861736334406, + "x1": 0.7272727272727273, + "y1": 0.7234726688102894 + }, + { + "marketingName": "Samsung Galaxy S7 edge", + "modelNames": [ + "SM-G935F", + "SM-G935L", + "SM-G9350", + "SM-G935U" + ], + "x0": 0.09999999999999998, + "y0": 0.255663430420712, + "x1": 0.9, + "y1": 0.627831715210356 + }, + { + "marketingName": "Samsung Galaxy S7", + "modelNames": [ + "SM-G930F", + "SM-G930X", + "SM-G930W8", + "SM-G930K", + "SM-G930L", + "SM-G930S", + "SM-G930R7", + "SAMSUNG-SM-G930AZ", + "SAMSUNG-SM-G930A", + "SM-G930VC", + "SM-G9300", + "SM-G9308", + "SM-G930R6", + "SM-G930T1", + "SM-G930P", + "SM-G930VL", + "SM-G930T", + "SM-G930U", + "SM-G930R4", + "SM-G930V" + ], + "x0": 0.06578947368421051, + "y0": 0.26282051282051283, + "x1": 0.9539473684210527, + "y1": 0.6217948717948718 + }, + { + "marketingName": "Samsung Galaxy S8+", + "modelNames": [ + "SC-03J", + "SCV35", + "SM-G955F", + "SM-G955N", + "SM-G955W", + "SM-G9550", + "SM-G955U", + "SM-G955U1" + ], + "x0": 0.15602836879432624, + "y0": 0.3054662379421222, + "x1": 0.8723404255319149, + "y1": 0.6334405144694534 + }, + { + "marketingName": "Samsung Galaxy S8", + "modelNames": [ + "SC-02J", + "SCV36", + "SM-G950F", + "SM-G950N", + "SM-G950W", + "SM-G9500", + "SM-G9508", + "SM-G950U", + "SM-G950U1" + ], + "x0": 0.1448275862068965, + "y0": 0.36538461538461536, + "x1": 0.8551724137931034, + "y1": 0.6955128205128205 + }, + { + "marketingName": "Samsung Galaxy S9+", + "modelNames": [ + "SC-03K", + "SCV39", + "SM-G965F", + "SM-G965N", + "SM-G9650", + "SM-G965W", + "SM-G965U", + "SM-G965U1" + ], + "x0": 0.11564625850340138, + "y0": 0.38782051282051283, + "x1": 0.8707482993197279, + "y1": 0.7275641025641025 + }, + { + "marketingName": "Samsung Galaxy S9", + "modelNames": [ + "SC-02K", + "SCV38", + "SM-G960F", + "SM-G960N", + "SM-G9600", + "SM-G9608", + "SM-G960W", + "SM-G960U", + "SM-G960U1" + ], + "x0": 0.12328767123287676, + "y0": 0.3557692307692308, + "x1": 0.863013698630137, + "y1": 0.6826923076923077 + }, + { + "marketingName": "Samsung Galaxy Z Flip 5G", + "modelNames": [ + "SCG04", + "SM-F7070", + "SM-F707B", + "SM-F707N", + "SM-F707U", + "SM-F707U1", + "SM-F707W" + ], + "x0": 0.12745098039215685, + "y0": 0.6365979381443299, + "x1": 0.8333333333333334, + "y1": 0.884020618556701 + }, + { + "marketingName": "Samsung Galaxy Z Flip LTE", + "modelNames": [ + "SCV47", + "SM-F7000", + "SM-F700F", + "SM-F700N", + "SM-F700U", + "SM-F700U1", + "SM-F700W" + ], + "x0": 0.19565217391304346, + "y0": 0.6806451612903226, + "x1": 0.8043478260869565, + "y1": 0.9 + }, + { + "marketingName": "Samsung Galaxy Z Flip3 5G", + "modelNames": [ + "SC-54B", + "SCG12", + "SM-F7110", + "SM-F711B", + "SM-F711N", + "SM-F711U", + "SM-F711U1", + "SM-F711W" + ], + "x0": 0.08088235294117652, + "y0": 0.5973154362416108, + "x1": 0.9264705882352942, + "y1": 0.912751677852349 + }, + { + "marketingName": "Samsung Galaxy Z Fold2 5G", + "modelNames": [ + "SM-F9160", + "SM-F916B", + "SM-F916N", + "SM-F916Q", + "SM-F916U", + "SM-F916U1", + "SM-F916W" + ], + "x0": 0.12959381044487428, + "y0": 0.32319078947368424, + "x1": 0.9226305609284333, + "y1": 0.6077302631578947 + }, + { + "marketingName": "Samsung Galaxy Z Fold3 5G", + "modelNames": [ + "SC-55B", + "SCG11", + "SM-F9260", + "SM-F926B", + "SM-F926N", + "SM-F926U", + "SM-F926U1", + "SM-F926W" + ], + "x0": 0.10526315789473684, + "y0": 0.4129032258064516, + "x1": 0.9473684210526316, + "y1": 0.7516129032258064 + }, + { + "marketingName": "Samsung Galaxy Z Flip4 5G", + "modelNames": [ + "SC-55C", + "SCG16", + "SM-F9360", + "SM-F936B", + "SM-F936N", + "SM-F936U", + "SM-F936U1", + "SM-F936W" + ], + "x0": 0.06617647058823528, + "y0": 0.5709677419354838, + "x1": 0.9264705882352942, + "y1": 0.8774193548387097 + }, + { + "marketingName": "Samsung Galaxy Z Fold4 5G", + "modelNames": [ + "SC-55C", + "SCG16", + "SM-F9360", + "SM-F936B", + "SM-F936N", + "SM-F936U", + "SM-F936U1", + "SM-F936W" + ], + "x0": 0.10447761194029848, + "y0": 0.45806451612903226, + "x1": 0.9328358208955224, + "y1": 0.7967741935483871 + }, + { + "marketingName": "Pixel (2016)", + "modelNames": [ + "Pixel" + ], + "x0": 0.415929203539823, + "y0": 0.1091703056768559, + "x1": 0.5752212389380531, + "y1": 0.18777292576419213 + }, + { + "marketingName": "Pixel 2 (2017)", + "modelNames": [ + "Pixel 2" + ], + "x0": 0.33884297520661155, + "y0": 0.0622568093385214, + "x1": 0.487603305785124, + "y1": 0.13229571984435798 + }, + { + "marketingName": "Pixel 3 (2018)", + "modelNames": [ + "Pixel 3" + ], + "x0": 0.17098445595854928, + "y0": 0.12224938875305623, + "x1": 0.7305699481865284, + "y1": 0.3863080684596577 + }, + { + "marketingName": "Pixel 3a (2019)", + "modelNames": [ + "Pixel 3a" + ], + "x0": 0.2195121951219512, + "y0": 0.14788732394366197, + "x1": 0.7463414634146341, + "y1": 0.4014084507042254 + }, + { + "marketingName": "Pixel 4 (2019)", + "modelNames": [ + "Pixel 4" + ], + "x0": 0.10188679245283017, + "y0": 0.15845070422535212, + "x1": 0.41132075471698115, + "y1": 0.3028169014084507 + }, + { + "marketingName": "Pixel 4a (2020)", + "modelNames": [ + "Pixel 4a" + ], + "x0": 0.4957983193277311, + "y0": 0.39096267190569745, + "x1": 0.6428571428571428, + "y1": 0.45972495088408644 + }, + { + "marketingName": "Pixel 4a (5G)", + "modelNames": [ + "Pixel 4a (5G)" + ], + "x0": 0.44339622641509435, + "y0": 0.3858093126385809, + "x1": 0.5849056603773585, + "y1": 0.4523281596452328 + }, + { + "marketingName": "Pixel 5", + "modelNames": [ + "Pixel 5" + ], + "x0": 0.4416243654822335, + "y0": 0.34988179669030733, + "x1": 0.5939086294416244, + "y1": 0.42080378250591016 + }, + { + "marketingName": "Pixel 5a (5G)", + "modelNames": [], + "x0": 0.44339622641509435, + "y0": 0.3858093126385809, + "x1": 0.5849056603773585, + "y1": 0.4523281596452328 + }, + { + "marketingName": "Pixel 6 Pro", + "modelNames": [ + "Pixel 6 Pro" + ], + "x0": 0.43621399176954734, + "y0": 0.540952380952381, + "x1": 0.5720164609053497, + "y1": 0.6038095238095238 + }, + { + "marketingName": "Pixel 6", + "modelNames": [ + "Pixel 6" + ], + "x0": 0.4565217391304348, + "y0": 0.5666666666666667, + "x1": 0.6, + "y1": 0.6313725490196078 + }, + { + "marketingName": "Pixel 7 Pro", + "modelNames": [ + "Pixel 7 Pro" + ], + "x0": 0.40888888888888886, + "y0": 0.33583489681050654, + "x1": 0.5955555555555556, + "y1": 0.4146341463414634 + }, + { + "marketingName": "Pixel 7", + "modelNames": [ + "Pixel 7" + ], + "x0": 0.40909090909090906, + "y0": 0.26143790849673204, + "x1": 0.6, + "y1": 0.35294117647058826 + } +] \ No newline at end of file diff --git a/android/src/main/res/raw/subtitles_cdw_instruction_de.srt b/android/src/main/res/raw/subtitles_cdw_instruction_de.srt deleted file mode 100644 index 6c660040..00000000 --- a/android/src/main/res/raw/subtitles_cdw_instruction_de.srt +++ /dev/null @@ -1,11 +0,0 @@ -1 -00:00:00,990 --> 00:00:04,000 -Halten Sie Ihre Gesundheitskarte an die Rückseite Ihres Smartphones. - -2 -00:00:06,100 --> 00:00:11,950 -Karte langsam hin- und herbewegen, bis eine Verbindung hergestellt wurde. - -3 -00:00:15,100 --> 00:00:19,030 -Karte ruhig halten und Anweisungen auf dem Display folgen \ No newline at end of file diff --git a/android/src/main/res/raw/subtitles_cdw_instruction_en.srt b/android/src/main/res/raw/subtitles_cdw_instruction_en.srt deleted file mode 100644 index 12ac6290..00000000 --- a/android/src/main/res/raw/subtitles_cdw_instruction_en.srt +++ /dev/null @@ -1,11 +0,0 @@ -1 -00:00:00,990 --> 00:00:04,000 -Hold your health card against the back of your smartphone. - -2 -00:00:06,100 --> 00:00:11,950 -Slowly move the card back and forth until a connection is established. - -3 -00:00:15,100 --> 00:00:19,030 -Hold card steady and follow instructions on display. \ No newline at end of file diff --git a/android/src/main/res/raw/subtitles_cdw_instruction_tr.srt b/android/src/main/res/raw/subtitles_cdw_instruction_tr.srt deleted file mode 100644 index 18be4975..00000000 --- a/android/src/main/res/raw/subtitles_cdw_instruction_tr.srt +++ /dev/null @@ -1,11 +0,0 @@ -1 -00:00:00,990 --> 00:00:04,000 -Sağlık kartınızı akıllı telefonunuzun arkasına doğru tutun. - -2 -00:00:06,100 --> 00:00:11,950 -Bağlantı kurulana kadar kartı yavaşça ileri geri hareket ettirin. - -3 -00:00:15,100 --> 00:00:19,030 -Kartı sabit tutun ve ekrandaki talimatları izleyin. \ No newline at end of file diff --git a/android/src/main/res/values-ar/strings.xml b/android/src/main/res/values-ar/strings.xml index 529ac0c0..9b57a389 100644 --- a/android/src/main/res/values-ar/strings.xml +++ b/android/src/main/res/values-ar/strings.xml @@ -1,503 +1,809 @@ - الوصفة الإلكترونية - موافق - إلغاء - تراجع - الساعة - في تمام %1$s - آخر تحديث في %1$s - فشل التحديث. الرجاءتحديث وصفاتك الطبية في وقت لاحق. - رقمي. سريع. آمن. - مرحبا في تطبيق الوصفة الإلكترونية - يمكنك هنا صرف الوصفات الطبية الإلكترونية في صيدلية من اختيارك أو مباشرة في مقر الصيدلية نفسه أو عبر الإنترنت. - المزيد من الوظائف مع بطاقتك الصحية - حدث وصفاتك الطبية الجديدة آليًا - معلومات عن تناول وجرعات أدويتك - استقبل إشعارات من الصيدلية الخاصة بك حول طلبك - شروط الاستخدام & سياسة الخصوصية - لكي تتمكن من استخدام التطبيق، يُرجى الموافقة على شروط الاستخدام والتأكيد على أنك اطلعت على سياسة الخصوصية. تُجمع فقط البيانات الضرورية لعمل الخدمات. - قرأت %s وأوافق عليها. - شروط الاستخدام - سياسة الخصوصية - تأكيد - متابعة - تأكيد - إضافة وصفات طبية - هل تلقيت نسخة مطبوعة من وصفة طبية؟ يمكنك إضافة وصفات إلى التطبيق عن طريق مسح كود الوصفة الطبية المطلوبة. - مفهوم - رقم الطلبية - كود الدخول - تم النسخ - شروط الاستخدام - سياسة الخصوصية - قبول شروط الاستخدام - قبول سياسة الخصوصية - الوصفات الطبية - الوصفات الطبية - الرسائل - الصرف - - - - - - . - - - مثل طبية الأمراض الجلدية - %s %s من %s%s تم التعرف عليه. مسح المزيد من الأكواد؟ - - - - - - . - - - - - - - - . - - - - - - - - . - - - تم رفض الوصول إلى الكاميرا - لكي تتمكن من استخدام الماسح الضوئي، يجب أن تسمح للتطبيق بالوصول إلى الكاميرا في إعدادات النظام. - ركز الكاميرا على كود الوصفة - وبالتالي فهو كود وصفة غير سارٍ - تم مسح هذا الرمز للوصفة من قبل - - - - - - . - - - إلغاء - ضوء الكاميرا - إلغاء المسح الضوئي لكود الوصفة الطبية؟ - إلغاء المسح الضوئي - المواصلة - إضافة بطاقة - ابدأ الآن - استخدام كافة الوظائف الآن - لتتمكن من استخدام جميع وظائف التطبيق، قم بتسجيل الدخول باستخدام بطاقتك الصحية. ويمكنك الحصول على هذه البطاقة وبيانات الدخول المطلوبة من شركة التأمين الصحي الخاصة بك. - ما تحتاج إليه: - بطاقة صحية تحتوي على رقم الدخول (CAN) - رقم التعريف الشخصي للبطاقة الصحية - إضافة بطاقة - يا للأسف … - للأسف لا يفي جهازك بالحد الأدنى من متطلبات تسجيل الدخول إلى تطبيق الوصفات الطبية الإلكترونية. - لماذا يوجد حد أدنى من المتطلبات للتسجيل باستخدام البطاقة الصحية؟ - يتكون رقم الدخول إلى بطاقتك (رقم الوصول إلى البطاقة - المعروف اختصارًا باسم CAN) من 6 أرقام. ستجد هذا الرقم في الركن الأيمن العلوي من مقدمة بطاقة التأمين الصحي الخاصة بك. إذا لم يكن هناك رقم وصول مكون من ستة أرقام، فستحتاج إلى بطاقة صحية جديدة من شركة التأمين الصحي الخاصة بك. - إدخال رقم الدخول - يمكنك إدخال أي أرقام تفضلها. - يمكن أن يتكون رقم التعريف الشخصي لك من 6 إلى 8 أرقام. - إدخال رقم التعريف الشخصي - يمكنك إدخال أي أرقام تعريف شخصي تريدها في الوضع التجريبي. - جرب مرة أخرى - جهز الآن بطاقتك الصحية الإلكترونية. - يمكن أن يختلف الوقت المطلوب الذي يحتاجه جهازك للاتصال بالخادم على حسب سرعة الإنترنت ونوع الجهاز. - فشل الاتصال بالخادم. - تحقق من اتصالك بالإنترنت وابدأ العملية مرة أخرى. - تم إدخال رقم تعريف شخصي خاطيء. - - - - - - . - - - تم إدخال CAN خاطيء - تجد رقم تسجيل الدخول أعلى يمينًا في بطاقتك الصحية. - تم إدخال رمز PIN خطأ أكثر من مرة. - يجب إلغاء قفل البطاقة الصحية باستخدام مفتاح فتح القفل الشخصي (PUK). - إلغاء - البحث عن بطاقة... - ضع بطاقتك الصحية على ظهر الجهاز. - لا يزال البحث جاريًا ... - ضع البطاقة ببطء على ظهر الجهاز. - نصيحة - يمكن لأغلفة حماية الجهاز أن تجعل الاتصال عبر NFC أكثر صعوبة. - تم التعرف على البطاقة - حاول عدم تحريك البطاقة الصحية. - تم العثور على البطاقة الصحية. من فضلك لا تحركها. - فُقد الاتصال - ضع بطاقتك الصحية من جديد على ظهر الجهاز - سجلت دخولك بنجاح - ملاحظة: يتم تنزيل الوصفات الطبية في آخر 100 يوم فقط. - تم تفعيل الوضع التجريبي - هل لديك بطاقة صحية تدعم تقنية NFC وترغب في تجربتها في الوضع التجريبي؟ - المتابعة بالبطاقة - المتابعة بدون بطاقة - تم تفعيل الوضع التجريبي - النسخة: %s - Build-Hash: %s - قائمة التنقيح - كود الوصفة الطبية - قم بمسح كود الوصفة الطبية في صيدليتك. - يحتوي هذا الكود الجماعي على %s وصفات طبية - الصرف في الصيدلية - أنت توجد في صيدلية وتريد صرف وصفتك الطبية. - اطلب أو احجز - أرسل وصفتك الطبية إلى الصيدلية وقرر الطريقة التي ترغب بها في تلقي الدواء. - تحتاج إلى بطاقة صحية سارية. - اختر الصيدلية - مثل صيدلية Pinguin أو العنوان - البحث عن الصيدليات بسهولة - حدد موقعك وابحث عن الصيدليات في منطقتك - إتاحة المقر - مفتوح حتى الساعة %s - مفتوح دائمًا - هيئة التحرير - الناشر - gematik GmbH\nFriedrichstraße 136\n10117 Berlin - المدير التنفيذي: الدكتور طبيب ماركوس ليك ديكن\nالمحكمة المختصة بالتسجيل: المحكمة الابتدائية في شارلوتنبورغ\nرقم السجل التجاري.: HRB 96351\nرقم تعريف ضريبة القيمة المضافة: DE241843684 - المسؤول عن المحتوى - الدكتور طبيب ماركوس ليك ديكن - الاتصال - ملاحظة - نسعى جاهدين من أجل استخدام لغة منصفة بين الجنسين. إذا لاحظت أية أخطاء، فإننا نتطلع إلى إرسال رسالة لنا عبر البريد الإلكتروني. - الوصفة التي تم مسحها - الدواء %s - حديث - تحديث - أرشيف - لم تقم بصرف أي وصفات حتى الآن - - - - - - . - - - تم صرفها في:%s - لم تقم بصرف أي وصفات حتى الآن - المنصة الألمانية الحديثة للطب الرقمي - كتابة بريد إلكتروني - فتح الموقع الإلكتروني - أهلًا وسهلًا - ابدأ تسجيل الدخول - اضغط على زر الفتح - الفتح - هل لديك أي أسئلة أو مشاكل في استخدام التطبيق؟ يمكنك الاتصال بنا عبر الخط الفني الساخن عبر الرقم %s. لقد أجبنا لكم بالفعل على العديد من الأسئلة في %s. - التسجيل - إلغاء - https://www.das-e-rezept-fuer-deutschland.de/ - das-e-rezept-fuer-deutschland.de - الإعدادات - اسم غير معروف - البطاقات الصحية - إضافة بطاقة - إلى التجربة - يتيح لك الوضع التجريبي استكشاف جميع أقسام التطبيق حتى بدون بطاقة صحية إلكترونية. - الوضع التجريبي - الأمان - قم بحماية معلوماتك الصحية من الوصول غير المصرح لهم. - عدم التأمين - لا يُنصح به - القياس البيومتري - يستخدم هذا التطبيق أثر الاستشعارات البيومترية أمانًا والذي يوفره جهازك. - تأمين الجهاز - لا يُنصح به - تعليمات قانونية - هيئة التحرير - حماية البيانات - شروط الاستخدام - تم تفعيل الوضع التجريبي - يعرض لك الوضع التجريبي جميع وظائف التطبيق - بدون أي بطاقة صحية. - هل ترغب في جولة استكشافية؟ - يعرض لك الوضع التجريبي جميع وظائف التطبيق - بدون أي بطاقة صحية. - ابدأ الوضع التجريبي - ليس لديك أي وصفات طبية في الوقت الراهن - تأمين بيانات الوصفات - حماية أفضل لبياناتك باستخدام بصمة الإصبع أو الوجه. - التفعيل الآن - التفاصيل - احتفظ بنظرة عامة - تحديد هذه الوصفة باعتبارها تم صرفها بمجرد حصولك على أدويتك. - تحديث الوصفات الطبية آليًا - وضيتم ع علامة \"تم الصرف\" بشكل آلي. - التسجيل الآن - لماذا أشاهد هذه المعلومات فقط؟ - تحظى معلوماتك الصحية بحماية خاصة - الدواء %1$d - وضع علامة \"تم الصرف\" - وضع علامة \"لم يتم الصرف\" - الحذف من هذا الجهاز - بروتوكول - تم المسح الضوئي في - الساعة %1$s - قابلة للصرف حتى %s - تفاصيل عن هذا الدواء - شكل الجرعة - حجم العبوة - الرقم المركزي الصيدلي (PZN) - تعليمات تناول الدواء - يُرجى الانتباه إلى تعليمات تناول الدواء في خطة علاجك أو تعليمات حجم الجرعة التي حددها لك طبيبك بشكل مكتوب. - الشخص المؤمن عليه - الاسم - العنوان - تاريخ الميلاد - التأمين الصحي / القائم بالدفع - الحالة - الرقم التأميني - الشخص واصف الدواء - الاسم - الإخصائية / الأخصائي - رقم الطبيب (LANR) - المؤسسة - الاسم - العنوان - رقم المُنْشَأَة - رقم الهاتف - البريد الإلكتروني - حادث عمل - يوم الحادث - رقم شركة التأمين على الحوادث أو صاحب العمل - هل ترغب في حذف هذه الوصفة بشكل دائم؟ - حذف - إلغاء - هل ترغب في إتاحة هذه الوصفة مرة أخرى أو إتاحة الجميع؟ - الكل - هذه فقط - السرعة مطلوبة هنا - يمكن أيضًا صرف هذا الدواء من الصيدلية ليلاً بدون رسوم خدمة الطوارئ. - يمكن تلقي مستحضرًا طبيًا بديلًا - يُسمح بالمستحضرات الطبية البديلة. نظرا للمتطلبات القانونية للتأمين الصحي الخاص بك، يمكن أن تسليمك بديل. - احجز بشكل مُلزم - اطلب خدمة المراسلة - اطلب خدمة التوصيل - يرجى الانتباه إلى أنه قد يتم تطبيق رسوم إضافية مقابل الأدوية الموصوفة أيضًا. - أوقات العمل - الموقع الإلكتروني - الحجز - هل ترغب في صرف الوصفات الطبية في %s بشكل نهائي؟ - الوصفات الطبية - الصرف - خدمة المراسلة - عنوان التسليم - كيف يمكننا المساعدة؟ - هل غيرت عنوان التسليم؟هل ترغب في إبلاغ الصيدلية بشيء آخر؟ - الاتصال الآن - يمكنك تغيير عنوان التسليم الخاص بك على الموقع الإلكتروني للصيدلية التي ترسل لك الطلبية. - إرسال - بروتوكول - بدون تحديد عمل - انتهت مدة الصلاحية - سارِ اليوم فقط - تغيير اسم مجموعة الوصفات - يمكنك إدخال إسمًا لمجموعة الوصفات هذه. - التسجيل - التسجيل - هاتف ذكي يدعم تقنية NFC ويعمل بنظام Android 7 على الأقل - تفعيل وظيفة NFC - يُرجى تفعيل وظيفة NFC بجهازك لتتمكن من تسجيل الدخول باستخدام بطاقتك الصحية. - تفعيل - كيف أحصل على بطاقة صحية جديدة؟ - هنا تساعدك شركة التأمين الصحي الخاصة بك. - كيف أحصل على رقم التعريف الشخصي؟ - تحصل على رقم التعريف الشخصي لبطاقتك الصحية في خطاب مستقل من شركة التأمين الصحي الخاصة بك. - هل ترغب في حفظ بيانات تسجيل الدخول للتسجيلات المستقبلية؟ - حفظ بيانات تسجيل الدخول - ملائم: تتم حماية بياناتك بطريقة بيومترية على الجهاز لهذا الغرض - لا يمكن التأمين - لا توجد أي استشعارات آمنة ولم يتم إعداد أي تأمين بيومتري. - عدم حفظ بيانات تسجيل الدخول - موفر البيانات: يتطلب إدخال بيانات تسجيل الدخول الخاصة بك في كل مرة تبدأ فيها تشغيل التطبيق - التصويب - إلى الصفحة الرئيسية - وضع علامة \"تم الصرف\" في - تم وضع علامة \"لم يتم الصرف\" في - العرض في شكل كود فردي - العرض في شكل كود جماعي - %s من %s - تم صرف الوصفات الطبية؟ - هل ترغب في تحديد الوصفات باعتبارها تم صرفها؟ - لم يتم الصرف - تم الصرف - يفتح الساعة %s - +49 800 277 377 7 - الخط الفني الساخن - فتح الماسح الضوئي للوصفات الطبية - الإعدادات - +49 800 277 377 7 - يُرجى تحديد هويتك باستخدام بصمة الإصبع أو الوجه. - ملاحظة - لن يتم تشغيل هذا التغيير إلا بعد غعاد تشغيل التطبيق. - موافق - المتابعة - ساعدنا على تحسين هذا التطبيق. يتم جمع جميع بيانات الاستخدام بشكل مجهول وتعمل حصريًا على تحسين تجربة الاستخدام. - السماح بالمتابعة - في حالة حدوث عطل أو خطأ في التطبيق، يرسل لنا التطبيق معلومات عن أسباب حدوثها. كما يتم إرسال إصدار نظام التشغيل وبيانات عن الأجهزة المستخدمة. - منع أخذ لقطات الشاشة - يمنع عرض الصور المصغرة للمعاينة عند التبديل بين التطبيقات - هل تسمح للوصفات الطبية الإلكترونية بتحليل سلوك الاستخدام دون إفصاح عن الهوية؟ - يتضمن ذلك معلومات عن الأجهزة والبرامج الموجودة على هاتفك، وإعدادات تطبيق الوصفات الطبية الإلكترونية وحجم الاستخدام، ولكنه لا يتضمن مطلقًا بيانات حول شخصك أو حالتك الصحية.\nوتُتاح البيانات فقط لشركة gematik GmbH بواسطة معالجي البيانات وتُحذف بعد 180 يومًا على أقصى تقدير. كما يمكنك إلغاء تفعيل التحليل في أي وقت من قائمة التطبيق.\nتمكننا هذه البيانات من فهم وتحسين الوظائف التي تُستخدم بشكل متكرر. كما يمكننا أيضًا تقييم المدة التي يجب دعم التكنولوجيا الأقدم فيها ومتى يجب علينا مثلًا تحديث إصدار نظام التشغيل بشكل إلزامي دون التأثير على (عدد كبير جدًا) من المستخدمين. - السماح - - - - - - . - - - اضغط هناك لكي تصرفها في صيدلية - الصرف الآن - عرض الكل - حذف الأمر - تم وضع علامة \"تم الصرف\" - تراجع - عرض المزيد - عرض أقل - معلومات تقنية - تسجيل الخروج - سيتم حذف جميع بيانات تسجيلك في الشبكة الصحية. لكن بيانات وصفتك الطبية تظل موجودة. - سيؤدي هذا إلى حذف بيانات تسجيلك. - تسجيل الخروج - إلغاء - هل ترغب في تسجيل الخروج من التطبيق؟ - أمان بيانات وصفاتك - يُرجى الانتباه إلى أن الأشخاص الذين تشارك معهم هذا الجهاز والذين قد يمكنهم تخزين الصفات البيومترية لهم على هذا الجهاز أو الذين لديهم رقم تعريف شخصي للجهاز أو نمط مسح أو كلمة مرور، قد يمكنهم أيضًا الوصول إلى وصفاتك الطبية. - الصرف المُلزم؟ - سيؤدي هذا إلى إرسال الوصفات الطبية الخاصة بك إلى هذه الصيدلية. لن تتمكن بعد ذلك من صرفها في أي صيدلية أخرى. - إلغاء - الصرف الآن - تم الصرف بنجاح - ستتصل بك الصيدلية في أقرب وقت ممكن لتوضيح تفاصيل التسليم معك. - أكمل طلبيتك في المتصفح - انتقل إلى الصفحة الرئيسية - تقوم الصيدلية التي تقدم طلبًا عبر البريد بإنشاء عربة تسوق لك تحتوي على أدويتك. قد تستغرق هذه العملية عدة دقائق. - اضغط على \"فتح عربة التسوق\" واستكمل طلبك على موقع الصيدلية. - إلى الصفحة الرئيسية - فشل الإرسال - كرر العملية - سيكون طلبك جاهزًا في العادة لك في القريب العاجل. للحصول على موعد محدد، يُرجى الاتصال بالصيدلية. - عربة التسوق الخاصة بك جاهزة - الحصول على كود الاستلام - تم استلام الرسالة - عرض كود الاستلام - فتح عربة التسوق - أظهر هذا الرمز إلى الصيدلية الخاصة بك. - كود الاستلام - لا توجد رسائل - لم تتلق أي رسائل حتى الآن - كانت الرسالة الواردة من الصيدلية للأسف فارغة. يُرجى الاتصال بالصيدلية الخاصة بك. - لم يتم إعداد بريد إلكتروني بالبرنامج - لا توجد نتائج - لم نتمكن من العثور على أي نتائج بكلمة البحث هذه. - تراخيص المصدر المفتوح - الاتصال - الاتصال بالخط الفني الساخن - كتابة بريد إلكتروني - المشاركة في الاستبيان - +49 800 277 377 7 - معرفة المزيد - عائلة مبتسمه - صيدلي يحمل هاتفًا ذكيًا في يده ويسره وجودك. - تحمل يدٌ هاتفًا ذكيًا في اليد وتقوم بمصادقة نفسها باستخدام البطاقة الصحية الإلكترونية الجديدة في التطبيق - ساعدنا في تحسين هذا التطبيق. - نريد: - تحليل حجم وكثافة مستخدمي التطبيق %s لتحسين سهولة الاستخدام. - إرسال رسائل الأعطال والأخطاء %s إلى المطورين. - التعرف على أنماط الخطأ في مرحلة مبكرة، لتحسين الخط الفني الساخن. - أرغب في المساعدة في تحسين هذا التطبيق. - يمكنك تغيير هذا القرار في أي وقت في إعدادات النظام. - دون إفصاح عن الهوية - متابعة - ويتضمن ذلك معلومات عن الأجهزة والبرامج الموجودة على هاتفك، وإعدادات تطبيق الوصفات الطبية الإلكترونية وحجم الاستخدام، ولكنه لا يتضمن مطلقًا بيانات حول شخصك أو حالتك الصحية. - وتُتاح البيانات فقط لشركة gematik GmbH بواسطة معالجي البيانات وتُحذف بعد 180 يومًا على أقصى تقدير. كما يمكنك إلغاء تفعيل التحليل في أي وقت من قائمة التطبيق. - تمكننا هذه البيانات من فهم وتحسين الوظائف التي تُستخدم بشكل متكرر. كما يمكننا أيضًا تقييم المدة التي يجب دعم التكنولوجيا الأقدم فيها ومتى يجب علينا مثلًا تحديث إصدار نظام التشغيل بشكل إلزامي دون التأثير على (عدد كبير جدًا) من المستخدمين. - تحسين التطبيق - الرفض - يظل التحليل دون إفصاح عن الهوية غير مفعل - %s شكرًا لك على المساعدة! - اطلب أو احجز - وضيتم ع علامة \"تم الصرف\" بشكل آلي. - قد يحدث بعض التأخير حتى إظهار الوصفات الطبية التي تم صرفها في قسم \"الأرشيف\". - موافق - يجب أن يتم التسجيل لكي تحذف الوصفات الطبية. - الإبلاغ عن خطأ - تم استلام رسالة غير صحيحة - أرسلت صيدلية رسالة بشكل غير صحيح. - رسالة بوجود خطأ من تطبيق الوصفة الإلكترونية - ترسل لنا هذه المعلومات لأغراض استكشاف الأخطاء. يُرجى ملاحظة أنه قد يتم أيضًا نقل عنوان بريدك الإلكتروني وربما اسمك الوارد فيه. إذا كنت لا تريد نقل هذه المعلومات كليًا أو جزئيًا، فيرجى حذفها من هذا البريد الإلكتروني. \n\n يتم حفظ جميع البيانات ومعالجتها فقط بواسطة شركة gematik GmbH أو الشركة المتعاقد معها لمعالجة رسالة الخطأ هذه. ويتم الحذف تلقائيًا، وذلك في موعد لا يتجاوز 180 يومًا بعد معالجة التذكرة. ولا نستخدم عنوان بريدك الإلكتروني إلا بغرض للاتصال بك بخصوص رسالة الخطأ هذه. عند وجود استفسارات أو رغبة في الحذف المبكر، يمكنك الاتصال بمسؤول حماية البيانات لنظام الوصفات الطبية الإلكترونية في أي وقت. يمكنك الاطلاع على المزيد من المعلومات في تطبيق الوصفات الإلكترونية في القائمة أسفل معلومات حماية البيانات. - التسجيل - يُرجى تحديد هويتك لتنزيل الوصفات. - تم صرفها في:%s - تلقيت مستحضرًا طبيًا بديلًا - يُرجى الانتباه إلى تعليمات تناول الدواء في خطة علاجك أو تعليمات حجم الجرعة التي حددها لك طبيبك بشكل مكتوب. - ملاحظة للصيدليات: يحصل هذا التطبيق على تفاصيل الاتصال والمعلومات حول الصيدليات من الموقعmein-apothekenportal.de التابع لاتحاد الصيدليات الألماني ج.م. هل اكتشفت خطأ أو ترغب في تصحيح البيانات؟ - معرفة المزيد - الصيدليات - فشلك المحاولة للأسف \uD83D\uDE15 - الرجاء التجربة مرة أخرى - هل لديك أسئلة أو مشاكل في استخدام التطبيق؟ يمكنك الاتصال بقسم الدعم الفني لدينا عبر الخط الساخن %s. - أجبنا لك من قبل على الكثير من الأسئلة في %s. - إدخال كلمة السر - متابعة - وسائل المساعدة في الاستخدام - التكبير - يتيح تكبير حجم التطبيق عبر ضم أو سحب الأصابع (الشد للتكبير). - ملاحظة - كلمة السر - قم بتأمين بياناتك بكلمة سر من اختيارك. - كلمة السر - حفظ - إظهار كلمة السر - إدخال كلمة السر - يمكنك إدخال أي أرقام أو حروف أو رموز خاصة تفضلها. - كرر كلمة السر - قوة كلمة السر - التوصيات:%s - كتابة بريد إلكتروني - يسرنا تعليقك - كلما كان محددًا، كلما كان أفضل - أثناء إرسال رسالتك سيتم نقل المعلومات التالية عبر الجهاز ونظام التشغيل المُستخدم: - نظام التشغيل - أندرويد %s (نسخة المطور%s) (آخر تحديث الأمان %s) - الطراز - %s%s(اسم الكود%s) - النمط - تنسيق غامق - تنسيق فاتح - اللغة - إرسال - تعقيب - بطاقة صحية - مفهوم - طلب بطاقة صحية جديدة. - يساعدك هذا التطبيق في طلب بطاقة صحية إلكترونية. ولن تتحمل هنا أي تكاليف. - يمكن الصرف قريبًا - لا يمكن لهذه الصيدلية استقبال الوصفات الإلكترونية في الوقت الحالي. - الوصفة الإلكترونية - جاهز للوصفة الإلكترونية - مفتوح حديثًا - خدمة المراسلة - إرسال - الفلتر - الفلتر المطلوب - الفرز - ربما لم يُسمح بإتاحة المكان في الضبط. - لا يوجد مقر متاح - صندوق التأمين الصحي - الرقم التأميني - إرسال الإيميل - اختر التأمين الصحي - يُرجى مراجعة البيانات - الرقم التأميني + الوصفة الإلكترونية + موافق + إلغاء + تراجع + الساعة + رقمي. سريع. آمن. + إضافة وصفات طبية + هل تلقيت نسخة مطبوعة من وصفة طبية؟ يمكنك إضافة وصفات إلى التطبيق عن طريق مسح كود الوصفة الطبية المطلوبة. + مفهوم + رقم الطلبية + كود الدخول + شروط الاستخدام + سياسة الخصوصية + الوصفات الطبية + تم رفض الوصول إلى الكاميرا + لكي تتمكن من استخدام الماسح الضوئي، يجب أن تسمح للتطبيق بالوصول إلى الكاميرا في إعدادات النظام. + ركز الكاميرا على كود الوصفة + وبالتالي فهو كود وصفة غير سارٍ + تم مسح هذا الرمز للوصفة من قبل + + تم التعرف على %s وصفة + تم التعرف على %s وصفات + + + . + + + إلغاء + ضوء الكاميرا + إلغاء المسح الضوئي لكود الوصفة الطبية؟ + إلغاء المسح الضوئي + المواصلة + ابدأ الآن + ما تحتاج إليه: + إدخال رقم الدخول + إدخال رقم التعريف الشخصي + جرب مرة أخرى + فشل الاتصال بالخادم. + تم إدخال رقم تعريف شخصي خاطيء. + + لديك عدد %s محاولة أخرى قبل وقف البطاقة. + لديك عدد %s محاولات أخرى قبل وقف البطاقة. + + + . + + + تم إدخال CAN خاطيء + تجد رقم تسجيل الدخول أعلى يمينًا في بطاقتك الصحية. + إلغاء + البحث عن بطاقة... + ضع بطاقتك الصحية على ظهر الجهاز. + لا يزال البحث جاريًا ... + ضع البطاقة ببطء على ظهر الجهاز. + نصيحة + يمكن لأغلفة حماية الجهاز أن تجعل الاتصال عبر NFC أكثر صعوبة. + تم التعرف على البطاقة + حاول عدم تحريك البطاقة الصحية. + تم العثور على البطاقة الصحية. من فضلك لا تحركها. + فُقد الاتصال + ضع بطاقتك الصحية من جديد على ظهر الجهاز + النسخة: %s + Build-Hash: %s + قائمة التنقيح + كود الوصفة الطبية + قم بمسح كود الوصفة الطبية في صيدليتك. + يحتوي هذا الكود الجماعي على %s وصفات طبية + الصرف في الصيدلية + أنت توجد في صيدلية وتريد صرف وصفتك الطبية. + اطلب أو احجز + أرسل وصفتك الطبية إلى الصيدلية وقرر الطريقة التي ترغب بها في تلقي الدواء. + حدد موقعك وابحث عن الصيدليات في منطقتك + إتاحة المقر + مفتوح حتى الساعة %s + مفتوح دائمًا + هيئة التحرير + الناشر + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + المدير التنفيذي: الدكتور طبيب ماركوس ليك ديكن\nالمحكمة المختصة بالتسجيل: المحكمة الابتدائية في شارلوتنبورغ\nرقم السجل التجاري.: HRB 96351\nرقم تعريف ضريبة القيمة المضافة: DE241843684 + المسؤول عن المحتوى + الدكتور طبيب ماركوس ليك ديكن + الاتصال + ملاحظة + نسعى جاهدين من أجل استخدام لغة منصفة بين الجنسين. إذا لاحظت أية أخطاء، فإننا نتطلع إلى إرسال رسالة لنا عبر البريد الإلكتروني. + المنصة الألمانية الحديثة للطب الرقمي + كتابة بريد إلكتروني + فتح الموقع الإلكتروني + أهلًا وسهلًا + ابدأ تسجيل الدخول + الفتح + التسجيل + إلغاء + الأمان + تعليمات قانونية + هيئة التحرير + حماية البيانات + شروط الاستخدام + التفاصيل + وضع علامة \"تم الصرف\" + وضع علامة \"لم يتم الصرف\" + شكل الجرعة + مقاس معياري + الشخص المؤمن عليه + الاسم + العنوان + تاريخ الميلاد + التأمين الصحي / القائم بالدفع + الحالة + الرقم التأميني + الشخص واصف الدواء + الاسم + الإخصائية / الأخصائي + رقم الطبيب (LANR) + المؤسسة + الاسم + العنوان + رقم المُنْشَأَة + رقم الهاتف + البريد الإلكتروني + حادث عمل + يوم الحادث + رقم شركة التأمين على الحوادث أو صاحب العمل + هل ترغب في حذف هذه الوصفة بشكل دائم؟ + حذف + إلغاء + يُسمح بالمستحضرات الطبية البديلة. نظرا للمتطلبات القانونية للتأمين الصحي الخاص بك، يمكن أن تسليمك بديل. + احجز بشكل مُلزم + اطلب خدمة المراسلة + اطلب خدمة التوصيل + يرجى الانتباه إلى أنه قد يتم تطبيق رسوم إضافية مقابل الأدوية الموصوفة أيضًا. + أوقات العمل + الموقع الإلكتروني + هل ترغب في صرف الوصفات الطبية في %s بشكل ملزم؟ + قابلة للصرف اليوم فقط كدافع ذاتي + التسجيل + تفعيل وظيفة NFC + يُرجى تفعيل وظيفة NFC بجهازك لتتمكن من تسجيل الدخول باستخدام بطاقتك الصحية. + تفعيل + التصويب + العرض في شكل كود فردي + العرض في شكل كود جماعي + %s من %s + تم صرف الوصفات الطبية؟ + هل ترغب في تحديد الوصفات باعتبارها تم صرفها؟ + لم يتم الصرف + تم الصرف + يفتح الساعة %s + +49 800 277 377 7 + الخط الفني الساخن + فتح الماسح الضوئي للوصفات الطبية + الإعدادات + ملاحظة + لن يتم تشغيل هذا التغيير إلا بعد غعاد تشغيل التطبيق. + موافق + منع أخذ لقطات الشاشة + يمنع عرض الصور المصغرة للمعاينة عند التبديل بين التطبيقات + هل تسمح للوصفات الطبية الإلكترونية بتحليل سلوك الاستخدام دون إفصاح عن الهوية؟ + معلومات تقنية + أمان بيانات وصفاتك + يُرجى الانتباه إلى أن الأشخاص الذين تشارك معهم هذا الجهاز والذين قد يمكنهم تخزين الصفات البيومترية لهم على هذا الجهاز أو الذين لديهم رقم تعريف شخصي للجهاز أو نمط مسح أو كلمة مرور، قد يمكنهم أيضًا الوصول إلى وصفاتك الطبية. + فشل الإرسال + لم يتم إعداد بريد إلكتروني بالبرنامج + لا توجد نتائج + لم نتمكن من العثور على أي نتائج بكلمة البحث هذه. + تراخيص المصدر المفتوح + الاتصال + الاتصال بالخط الفني الساخن + المشاركة في الاستبيان + +49 800 277 377 7 + معرفة المزيد + أرغب في المساعدة في تحسين هذا التطبيق. + ويتضمن ذلك معلومات عن الأجهزة والبرامج الموجودة على هاتفك، وإعدادات تطبيق الوصفات الطبية الإلكترونية وحجم الاستخدام، ولكنه لا يتضمن مطلقًا بيانات حول شخصك أو حالتك الصحية. + وتُتاح البيانات فقط لشركة gematik GmbH بواسطة معالجي البيانات وتُحذف بعد 180 يومًا على أقصى تقدير. كما يمكنك إلغاء تفعيل التحليل في أي وقت من قائمة التطبيق. + تمكننا هذه البيانات من فهم وتحسين الوظائف التي تُستخدم بشكل متكرر. كما يمكننا أيضًا تقييم المدة التي يجب دعم التكنولوجيا الأقدم فيها ومتى يجب علينا مثلًا تحديث إصدار نظام التشغيل بشكل إلزامي دون التأثير على (عدد كبير جدًا) من المستخدمين. + تحسين التطبيق + يظل التحليل دون إفصاح عن الهوية غير مفعل + %s شكرًا لك على المساعدة! + ملاحظة + قد يحدث بعض التأخير حتى إظهار الوصفات الطبية التي تم صرفها في قسم \"الأرشيف\". + موافق + التسجيل + يُرجى تحديد هويتك لتنزيل الوصفات. + تم صرفها في:%s + ملاحظة للصيدليات: يحصل هذا التطبيق على تفاصيل الاتصال والمعلومات حول الصيدليات من الموقعmein-apothekenportal.de التابع لاتحاد الصيدليات الألماني ج.م. هل اكتشفت خطأ أو ترغب في تصحيح البيانات؟ + معرفة المزيد + الصيدليات + فشلك المحاولة للأسف \uD83D\uDE15 + الرجاء التجربة مرة أخرى + إدخال كلمة السر + متابعة + وسائل المساعدة في الاستخدام + التكبير + يتيح تكبير حجم التطبيق عبر ضم أو سحب الأصابع (الشد للتكبير). + ملاحظة + كلمة السر + قم بتأمين بياناتك بكلمة سر من اختيارك. + كلمة السر + حفظ + إظهار كلمة السر + كرر كلمة السر + التوصيات:%s + كتابة بريد إلكتروني + أثناء إرسال رسالتك سيتم نقل المعلومات التالية عبر الجهاز ونظام التشغيل المُستخدم: + يمكن الصرف قريبًا + لا يمكن لهذه الصيدلية استقبال الوصفات الإلكترونية في الوقت الحالي. + مفتوح حديثًا + خدمة المراسلة + إرسال + الفلتر + الفرز + لا يوجد مقر متاح + مفهوم + كلمة المرور المكرر مطابقة + + + يتبقى عدد %s يومًا للصرف كدافع ذاتي + + + + يتبقى عدد %sمن الأيام للصرف كدافع ذاتي + + + + سار لمدة %s يومًا + + + + سارٍ لمدة %s أيام + + فتح الماسح الضوئي + نقوم بمعالجة معلومات جهازك!\nيستخدم هذا التطبيق مجموعة ML Kit من جوجل لقراءة رمز الوصفة الطبية. إذا اخترت \"قبول\" ، فأنت توافق على أنه يجوز لشركة جوجل من وقت لآخر الوصول إلى معلومات الجهاز ومعالجتها لغرض تحليل الاستخدام والتشخيصات وإعداد ML Kit. ويحق لك إلغاء موافقتك في أي وقت دون التأثير على قانونية المعالجة التي تمت معالجتها في وقت سابق. ومع ذلك، سيؤدي الرفض إلى عدم القدرة على استخدام الماسح الضوئي لرمز الوصفة الطبية. + موافق + إلغاء + خطأ 20 10 76631 + شهادة بطاقتك الصحية غير صالحة. هل ربما انتهت صلاحية بطاقتك؟ يُرجى الاتصال بشركة التأمين الصحي الخاصة بك. + محاولات تسجيل دخول غير ناجحة + + + تبين وجود عدد %s محاولة تسجيل خاطئة + + + + تبين وجود عدد %s محاولات تسجيل خاطئة + + اختر الطريقة الأكثر أمانًا + يمكن أن يكون هذا بصمة الإصبع أو نمط التمرير السريع أو ما شابه ذلك + الرموز + رمز الوصول + رموز الدخول الموحّد (SSO) + لا يوجد رمز وصول متاح + لا يتوفر الرمز الموحد المميز (SSO) + تم النسخ إلى الحافظة + اضغط لإضافة الرمز المميز إلى الحافظة + سارِ اليوم فقط + السماح + لا يوجد اتصال بالخادم + يُرجى المحاولة من جديد بعد دقائق قليلة + إعادة التحميل + إظهار الرمز المميز + كيف ترغب في تأمين هذا التطبيق؟ + ملاحظة + لم يتم تأمين الجهاز. + ننصحك بحماية بياناتك الطبية بشكل إضافي من خلال تأمين الجهاز على سبيل المثال من خلال تعيين رمز المرور أو البصمة. + عدم إظهار هذه الملاحظة في المستقبل. + فشل الاتصال. لم يتمكن من الاتصال بالإنترنت. + فشل الاتصال بالسيرفر. كود الحالة %s. + فشل الاتصال بالسيرفر. خطأ VAU + تحذير + لا يحظى هذا الجهاز بالثقة الكاملة + لأسباب تتعلق بالأمن، لا يُنصح باستخدام هذا التطبيق بالأجهزة التي تعمل بنظام التجذير. + أنا على دراية بالمخاطر ومع ذلك أرغب في المواصلة. + لماذا تعتبر الأجهزة التي تعمل بنظام التجذير أحد المخاطر الأمنية المحتملة؟ + معرفة المزيد + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + اسم البروفايل + يُرجى إدخال اسم للبروفايل الجديد. + اسم البروفايل + الصفحات الشخصية + إضافة بروفايل + بطاقة صحية + التواصل مع شركة التأمين الصحي + لكي تتمكن من التسجيل في هذا التطبيق، أنت بحاجة إلى بطاقة صحية تعمل بنظام الاتصال قريب المدى وكذلك رقم التعريف الشخصي الخاص بها. + سوف تحصل عليها مجانا من شركة التأمين الصحي لك. ويجب عليك إثبات هويتك بوثيقة هوية رسمية. + هكذا يمكنك التعرف على البطاقة الصحية ذات الاتصال قريب المدى + اختر التأمين الصحي + لم يتم الاختيار + ما الذي ترغب في طلبه؟ + لا يمكن الاتصال عبر هذا التطبيق + يُرجى استخدام القنوات المعتادة للتواصل مع التأمين الخاص بك. + بطاقة صحية و رمز PIN + رمز PIN فقط + تواصل مع شركة التأمين الصحي الخاصة بك + التسجيل في تطبيق الوصفة الإلكترونية + لا يجوز ان يكون هذا الحقل فارغًا. + يوجد بروفايل بالفعل بنفس الاسم المذكور. + البروفايل + %s تم الاختيار + صورة الخلفية + Frühlingsgrau + Sonnentau + Es! Ist! Rosa! + Baum + Blauer Mond September + لم يتم التسجيل + تم الاتصال + كان آخر اتصال في %s + حذف البروفايل؟ + سيؤدي هذا إلى حذف جميع بيانات البروفايل الموجودة بهذا الجهاز. ستبقى الوصفات الطبية الخاصة بك في الشبكة الصحية كما هي. + حذف + إلغاء + حذف البروفايل + ترغب في حذف البروفايل الأخير. + يحتاج التطبيق إلى بروفايل واحد على الأقل. يُرجى إدخال اسم للبروفايل الجديد. + خطأ 20 10 76831 + لم يمكن العثور على قائمة البطاقات الصحية. يُرجى المحاولة في وقت لاحق. + تجدون في البوابة الوطنية للصحة معلومات مُراجعة تخصصيًا عن الأمراض وأكواد ICD وموضوعات الرعاية والتمريض. + افتح gesund.bund.de + وقد غيرنا اللوائح التنظيمية لحماية البيانات + تطورت تطبيقات الوصفات الطبية الإلكيترونية. وقد نتج عن هذا التطوير ضرورة تحديث اللوائح التنظيمية لحماية البيانات لدينا. + فتح سياسة الخصوصية + تغير هذا منذ %s: + ماذا يحدث عندما تفتح التطبيق؟ + ماذا يحدث عندما أستخدم خاية الكاميرا / اقرأ الوصفات الطبية بالكاميرا؟ + اختر الصفحة الشخصية + معالجة الصفحات الشخصية + لا توجد وصفات طبية جديدة + + + تم تحديث %s من الوصفة + + + + تم تحديث %s من الوصفات + + قابلة للصرف + قيد الصرف + تم الصرف + غير معروف + التفاصيل + عرض بروتوكول الدخول + يمكنك أن ترى هنا من وصل إلى وصفاتك الطبية + المقصود به هو مفتاح دخول لخدمة الوصفات الطبية + بروتوكول الدخول + لا يوجد بروتوكول للدخول + تتلقى بروتوكل الدخول بعد التسجيل في خدمة الوصفات الطبية. + لا يوجد بروتوكول للدخول بعد. + آخر تحديث في %s + جاري معالجة الوصفة الطبية في الوقت الحالي ولا يمكن صرفها. + الموافقة + يبدو أن المحاولة فشلت + نحن على علم بأن الربط بالبطاقة الصحية له عيوبه. لهذا فمن المقرر أن يكون التسجيل في المستقبل ممكنًا أيضًا عبر تطبيق تأمين صحي معتمد بالفعل.\n\nنحن نعمل أيضًا على تمكين صرف الوصفات الطبية رقميًا دون تسجيل.\n\nهل لاحظت أي شيء أثناء هذه العملية تود مشاركته معنا؟ يرجى الكتابة إلينا ، ويسعدنا أيضًا تلقي تعليقات نقدية للغاية. + نصائح الاتصال + حسن من قوة شبكة الاتصال + انزع عند الضرورة علبة الحماية. + قم بهز الجهاز ثم اقطع الاتصال، وابحث عن الموضع المثالي في نصف قطر صغير. + حرك الجهاز بشكل بطيء جدًا عبر البطاقة. + ضع الجهاز على البطاقة مباشرة. + للقيام بهذا ضع البطاقة الصحية على سطح مستوٍ (مثل الطاولة). + حسن من قوة شبكة الاتصال + انتبه إلى مكان تواجد المستشعر بخاصية الاتصال قريب المدى + اعرف المكان الذي يوجد به مستشعر بخاصية الاتصال قريب المدى في جهازك (هنا مثلًا قائمة بأجهزة %s). + يمكن أن يختلف موضع مستشعر بخاصية الاتصال قريب المدى داخل مجموعة الطراز بشكل جزئي (هنا مثلًا البيانات الخاصة بـ %s). + النصيحة التالية + متابعة + إغلاق + التجربة + اكتب لنا + تصريح البحث عن الصيدلية + الصرف + الوصفة الطبية التي تم مسحها + تم المسح الضوئي في %s + وضع علامة \"تم الصرف\" في %s + كيف تريد المواصلة؟ + اطلب + متوفر في القريب العاجل + احجز للاستلام الآن أو التسليم عبر خدمة التوصيل أو خدمة التسليم بالبريد + الحفظ للطلب لاحقًا + حفظ الوصفات الطبية على الجهاز + + + المواصلة مع الوصفة %s + + + + المواصلة مع الوصفات %s + + فضل الارتباط بالبطاقة الصحية + بروفايلك الحالي مرتبط بالفعل ببطاقة صحية أخرى (رقم تأمين صحي %s). + بطاقتك الصحية مرتبطة بالفعل ببروفايل آخر. يُرجى تغيير البروفايل %s. + طلبي + احجز الآن + اطلب الآن + حفظ + بيانات الاتصال والعنوان + الاتصال + رقم الهاتف + يُرجى إدخال رقم الهاتف من أجل التواصل معك. + البريد الإليكتروني (اختياري) + عنوان التسليم + الاسم الأول والأخير + يُرجى إدخال الاسم الأول والأخير من أجل التواصل معك. + الشارع ورقم المنزل + يُرجى إدخال الشارع ورقم المنزل من أجل التواصل معك. + العنوان التكميلي (اختياري) + الرمز البريدي والمكان + يُرجى إدخال الرمز البريدي والمكان من أجل التواصل معك. + إرشادات التسليم (اختياري) + سيتم إرسال الوصفات الطبية الخاصة بك إلى هذه الصيدلية. لن تتمكن بعد ذلك من صرفها في أي صيدلية أخرى. + بيانات الاتصال وعنوان التسليم + الوصفات الطبية + نحتاج إلى بيانات الاتصال الخاصة بك من أجل الحصول على المشورة عن طريق الصيدلية وإبلاغك بحالة طلبك في الوقت الراهن. + قم بإدخال بيانات الاتصال + نحتاج إلى المزيد من بيانات الاتصال + تم الطلب بنجاح + ستتصل بك الصيدلية الخاصة بك في أقرب وقت. + إغلاق + حذف التغييرات؟ + الحذف + للقيام بعملية البحث يستخدم دليل الصيدليات الإحداثيات الجغرافية التي تم تحديدها بمساعدة OpenStreetMap (خريطة الشارع المفتوحة). نشكر المشروع على هذه المساعدة. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + الاستخدام وحماية البيانات + متابعة + لقد تلقيت رقم التعريف الشخصي الخاص بك في خطاب من شركة التأمين الصحي الخاصة بك. + تم استلام رمز PIN + رمز PIN + تحقق من الاتصال بالإنترنت وإعدادات الوقت / التاريخ لجهازك. + للمصادقة اضغط على \"إلغاء الحظر\". + هل تم الإغلاق؟ يرجى التحقق من بيانات الدخول البيومترية الخاصة بك على هذا الجهاز. + نسيت كلمة المرور؟ يرجى حذف التطبيق ثم إعادة تثبيته. يمكنك معرفة السبب في %s + نطاق المساعدة + حجم العبوة والوحدة + المادة الفعالة + كمية المادة الفعالة + اسم الشحنة + صالح حتى + الصنف + لقاح + الموافقة + تراجع + ملاحظة + هل تساعدنا في تحسين هذا التطبيق؟ + اختر كلمة مرور خاصة + يجب أن تحتوي كلمة المرور على ثمانية حروف على الأقل + قوة كلمة المرور غير كافية + قوة كلمة المرور كافية + كلمة المرور مرئية + كلمة المرور غير مرئية + القياس البيومتري + كلمة المرور + انتظر الرد + لا توجد وصفات طبية + ليس لديك أي وصفات طبية لم تُصرف في الوقت الراهن + تحديث + تسجيل الخروج آليًا + لأسباب أمنية، يتم إنهاء الاتصال بخادم الوصفات بعد 12 ساعة. أعد الاتصال للحصول على الوصفات القائمة. + التوصيل + هل تلقيت نسخة ورقية؟ + أضف الوصفات إلى قائمتك عن طريق النقر على زر المسح في الزاوية اليمنى العليا. + المسح الضوئي للنسخة الورقية + يجب عليك تسجيل الدخول لتلقي الوصفات تلقائيا. + التسجيل + لا توجد وصفات طبية تم صرفها + يتم هنا عرض الوصفات الطبية الخاصة بك التي تم صرفها. لأسباب تتعلق بحماية البيانات ، سيتم حذف وصفاتك من خادم الوصفات بعد 100 يوم. + لا توجد وصفات طبية تم صرفها + يتم هنا عرض الوصفات الطبية الخاصة بك التي تم صرفها. أضف الوصفات الطبية عن طريق الفحص لبدء الصرف. + إدارة الأجهزة + الأجهزة المتصلة + مسجل منذ %s (هذا الجهاز) + مسجل منذ %s + حديث + أرشيف + الصرف مرة أخرى؟ + ملحوظة: الصيدلية الأولى التي تقبل الوصفة الطبية تمنع معالجتها من قبل صيدلية أخرى. + إلغاء + موافق + + + أرسلت الوصفة %s من قبل إلى الصيدلية. صرفها مع ذلك من جديد؟ + + + + أرسلت بعض هذه الوصفات %s من قبل إلى الصيدلية.هل ترغب مع ذلكفي إرسال وصفات أخرى؟ + + لأسباب أمنية ، يتم إنهاء الاتصال بخادم الوصفات بعد 12 ساعة. لإعادة الاتصال ، تحتاج إلى بطاقة صحية ورقم تعريف شخصي لكل عملية اتصال. + رمز PIN + أدخل رقم التعريف الشخصي (البطاقة الصحية). + متابعة + المصادقة + الأجهزة المتصلة + حذف الجهاز؟ + إلغاء + الحذف + حذف هذا الجهاز؟ + هل ترغب في حذف %s؟ + إذا حذفت %s فسوف يتم بعد 12 ساعة بحد أقصى فصل الاتصال بسيرفر الوصفة. + جاري تحميل الأجهزة... + لا توجد أجهزة + لا توجد أجهزة متصلة بهذه البطاقة الصحية. + حاول مرة أخرى + يا للأسف :-( + لم يتمكن من تحميل قائمة الجهاز. + لا يوجد اتصال + لا يوجد اتصال بالإنترنت + أدوية وضمادات + مواد مخدرة + تسليم الأدوية الموصوفة طبيًا وفق المادة 4 من لائحة الأدوية الموصوفة + هل تحتاج إلى المساعدة؟ + لقد قمنا بجمع بعض النصائح لك لحل المشكلات الأكثر شيوعًا. + ابدأ نصائح الاتصال + تم المسح الضوئي في: %s + الوصفة الطبية التي تم مسحها + إلغاء الحظر + تم حظر البطاقة + تم إدخال رقم تعريف شخصي خاطيء ثلاث مرات. لذلك تم حظر بطاقتك لأسباب أمنية. + إلغاء حظر البطاقة + أدخل رمز PUK + لقد تلقيت مع رقم التعريف الشخصي الخاص بك رمز PUK مكون من 8 حروف في خطاب من شركة التأمين الصحي الخاصة بك. + اختيار رقم PIN جديد + يمكنك اختيار رقم التعريف الشخصي الجديد (PIN) بنفسك (من 6 إلى 8 أرقام). + سجلت رقم التعريف الشخصي؟ + يرجى تدوين رقم التعريف الشخصي الخاص بك والاحتفاظ به في مكان آمن. + إلغاء + تم إدخال رمز PUK خاطيء. + موافق + لا يمكن إلغاء الحظر + لقد وصلك باستخدام مفتاح فك القفل الشخصي إلى العدد الأقصى لعمليات إلغاء الحظر بالبطاقات أو أدخلته بشكل خاطيء مرات متكررة. يُرجى التوجه إلى شركة التأمين الخاصة بك. + يمكنك استخدام مفتاح فك القفل الشخصي حتى 10 محاولات إلغاء الحظر. + تم إلغاء حظر البطاقة + ما تحتاج إليه: + بطاقتك الصحية + مفتاح فك القفل الشخصي لبطاقتك الصحية + متابعة + البطاقة الصحية + طلب بطاقة جديدة + التسجيل + استقبل الوصفات الطبية أونلاين وأرسلها إلى الصيدلية. + بطاقة صحية مزودة بتقنية الإتصال اللاسلكية + رقم التعريف الشخصي للبطاقة الصحية + ليس لديك حتى الآن بطاقة صحية مزودة بتقنية الإتصال اللاسلكية ورقم تعريف شخصي؟ + اطلب الآن + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + أو: قم بتسجيل الدخول باستخدام %s. + تطبيق تأمينك الصحي + "تجد رقم الدخول إلى بطاقتك (رقم الوصول إلى البطاقة - المعروف اختصارًا باسم CAN) في الركن الأيمن العلوي من مقدمة بطاقة التأمين الصحي الخاصة بك. " + لا تحتوي بطاقتي على رقم الدخول + + + لديك عدد %s محاولة إضافية قبل حظر بطاقتك. + + + + لديك عدد %s محاولات إضافية قبل حظر بطاقتك. + + ضع بطاقتك الصحية على ظهر الهاتف + يمكن أن تستمر العملية التالية حتى 30 ثانية. + ضع البطاقة %s على ظهر الهاتف. + في الجزء العلوي يمينًا + في الجزء العلوي في الوسط + في الجزء العلوي يسارًا + في الجزء الأوسط يمينًا + في الوسط + في الجزء الأوسط يسارًا + في الجزء السفلي يمينًا + في الجزء السفلي في الوسط + في الجزء السفلي يسارًا + المساعدة + تم الإرسال قبل %s دقيقة + تم الإرسال في %s + تم الإرسال للتو + تم الإرسال الساعة %s + لم يعد صالحًا + التسجيل بالتطبيق + اختر التأمين الصحي + لم تجد ما تبحث عنه؟ يتم زيادة هذه القائمة باستمرار. يتم في الوقت الحالي دعم التسجيل بالبطاقة الصحية لكل شركة تأمين صحي. + ملاحظة من تطبيق الوصفة الإلكترونية + نحن سعداء بملاحظاتك. يرجى استخدام المساحة أدناه مع توخي الدقة قدر الإمكان: + مفتاح فك القفل الشخصي + إغلاق + يا للأسف ... + للأسف لا يفي جهازك بالحد الأدنى من متطلبات تسجيل الدخول إلى تطبيق الوصفات الطبية الإلكترونية. يلزم توفر نسخة أندرويد 7 وشريحة الاتصال قريب المدى على الأقل للمصادقة الآمنة باستخدام بطاقتك الصحية. + معرفة المزيد + حفظ بيانات تسجيل الدخول؟ + حفظ + عدم الحفظ + ملاحظة + لأسباب أمنية ، يتم إنهاء الاتصال بخادم الوصفات بعد 12 ساعة. لإعادة الاتصال ، تحتاج إلى بطاقة صحية ورقم تعريف شخصي لكل عملية اتصال. + إعداد التأمين البيومتري + لا يمكن حفظ بيانات الدخول. قم بإعداد التأمين البيومتري (مثل بصمة الإصبع) على جهازك مسبقًا. + إلغاء + الإعدادات + ملاحظة + الموافقة + أمان بيانات وصفاتك + \ \"يستخدم هذا التطبيق المستشعر البيومتري الأكثر أمانًا الذي يوفره جهازك لتخزين بيانات الاعتماد الخاصة بك في منطقة آمنة من ذاكرة الجهاز. \\" + يسمح لكنظام التأمين البيومتري لبيانات الدخول الخاصة بك بفتح هذا التطبيق في المستقبل دون الحاجة إلى إدخال رقم التعريف الشخصي أو البطاقة الصحية ، وعرض الوصفات الطبية أو استدعائها أو استرداد قيمتها أو حذفها. + يُرجى الانتباه إلى أن الأشخاص الذين تشارك معهم هذا الجهاز والذين قد يمكنهم تخزين الصفات البيومترية لهم على هذا الجهاز أو الذين لديهم رقم تعريف شخصي للجهاز أو نمط مسح أو كلمة مرور، قد يمكنهم أيضًا الوصول إلى وصفاتك الطبية. + فشلت المحاولة للأسف + لم تنجح المصادقة باستخدام باستخدام تطبيق صندوق التأمين الصحي. + انتهت الصلاحية في %s + تم بالفعل حذف الوصفة من الخادم + يرجى تصحيح المدخلات الخاصة بك أو تجاهل التغييرات + التصويب + البيانات المؤمن عليه + الاسم + التأمين الصحي + الرقم التأميني + رقم الدخول (CAN) + التسجيل + تسجيل الخروج + يجب عليك تسجيل الدخول لتلقي الوصفات تلقائيا. + حفظ + يتغيرون + تعديل الصورة الشخصية + متابعة + الخادم لا يستجيب + الرجاء معاودة المحاولة في وقت لاحق. + حاول مرة أخرى + ابحث عن التأمين + هل تريد الاتصال بخادم الوصفات الآن؟ + تم تسجيل الدخول بنجاح + فقد الاتصال + هل تريد الاتصال بخادم الوصفات الآن؟ + لا توجد رموز + ستتلقى رمزًا مميزًا عند تسجيل الدخول إلى خدمة الوصفات الطبية.\n + الطلب #٪ s + حدد رقم التعريف الشخصي المطلوب + إلغاء حظر البطاقة + اختر PIN + كرر PIN + الإدخالات تختلف عن بعضها البعض. + لا توجد أوامر + ليس لديك أي طلبات حتى الآن. + الآن + الساعة %s + عربة التسوق جاهزة + تمت إضافة الوصفة إلى عربة التسوق الخاصة بك. يرجى زيارة موقع الصيدلية لإتمام الطلب. + فتح عربة التسوق + أظهر رمز الاستلام هذا في الصيدلية. + الحصول على كود الاستلام + لا يمكن عرض الرسالة + الرجاء الاتصال بالصيدلية الخاصة بك ( %s ). + عرض رابط عربة التسوق + عرض كود الاستلام + اعرض الرسالة + %s الساعة %s + تم إرسال الوصفة إلى %s . + نظرة عامة على الطلب + جديد + مسار + أمر + مجاني للمتصل. أوقات الخدمة: الاثنين - الجمعة 8:00 صباحًا - 8:00 مساءً باستثناء أيام العطلات الوطنية + الصيدلية + حدد رقم التعريف الشخصي المطلوب + تم حفظ رقم التعريف الشخصي المطلوب + لا يمكن حفظ رقم التعريف الشخصي المطلوب + الوصفة الإلكترونية + حاليا مفتوحة وقريبة مني + مصنف بواسطة … + ابدأ البحث + إلغاء + سيتم استبدالها لك + الاحالة المباشرة + الصرف + رقم الهاتف (اختيارى) + البحث بالاسم أو العنوان + لا توجد معلومات صيدلية صالحة + لم يتم العثور على معلومات حالية حول هذه الصيدلية. سيتم حذف الإدخال الخاص بهذه الصيدلية. + موافق + دليل الصيدلة غير متاح + حاليا لا يمكن استرجاع أي معلومات حالية عن هذه الصيدلية. الرجاء التحقق من اتصال الانترنت الخاص بك. + إلغاء + حاول مرة أخرى + حفظ البيئة + تسجيل الدخول غير ممكن + يبدو أن بيانات اعتماد تسجيل الدخول الخاصة بك قد تغيرت. يرجى التسجيل مرة أخرى ببطاقتك الصحية. + إلغاء + التسجيل + الملف الشخصي 1 + قريب مني + ليس لديهم وصفات طبية قابلة للاسترداد + يمكن استردادها لاحقًا + قابل للاسترداد من %s + %s / %s + تحسينات المنتج + تحليل مجهول + ساعدنا في جعل هذا التطبيق أفضل. يتم جمع جميع بيانات المستخدم بشكل مجهول ويتم استخدامها فقط لتحسين تجربة المستخدم. + أمن الجهاز + اعدادات شخصية + وسائل المساعدة في الاستخدام + تحسينات المنتج + الوصفة المضافة + الوصفة متاحة بالفعل + حدث خطأ أثناء الاستيراد + حذف + الوصفة الطبية التي تم مسحها + يمكن تلقي مستحضرًا طبيًا بديلًا + تم الإرسال في %s في %s + استرداد في %s في %s + نسيت رقم التعريف الشخصي + + + وصفة %s + + + + %s وصفات + + لقد قرأت وقبلت سياسة الخصوصية وشروط الاستخدام. + سياسة الخصوصية + شروط الاستخدام + نريد: + تحسين سهولة الاستخدام. + كشف الأخطاء والأعطال. + يتم بالطبع جمع جميع البيانات بشكل مجهول. + يمكنك تغيير هذا القرار في أي وقت في إعدادات النظام. + المواصلة + الموافقة + يستخدم هذا التطبيق الطريقة الأكثر أمانًا التي يوفرها جهازك. + حفظ + يختار + الدواء + اسم تجاري + نعم + رقم + جرعة + تاريخ المسألة + noctu + سيتم استبدال هذه الوصفة لك كجزء من العلاج. + بدون بيان + لا توجد رسوم خدمة الطوارئ + دفع اضافي + الدواء + مذكرات التسليم + مؤهلة وفقًا لـ BVG + تحضير بديل + اسم الوصفة + التعبئة والتغليف + تعليمات صياغة + وصف + معطى بواسطة + الصادر في: + المادة الفعالة + المنصوص عليها + استلام + ما هو التعيين المباشر؟ + في حالة الإحالات المباشرة ، يتم استرداد وصفة طبية من عيادة أو مستشفى مباشرة في الصيدلية. لا يتعين على الأشخاص المؤمن عليهم اتخاذ أي إجراء ولا يمكنهم التدخل في عملية الاسترداد. \n\n يتم سرد الإحالات المباشرة في تطبيق الوصفات الطبية الإلكترونية لجعل علاجك أكثر شفافية بالنسبة لك. + لا توجد رسوم خدمة الطوارئ + على عجل هو ترتيب اليوم هنا. يمكن أيضًا ملء هذه الوصفة الطبية ليلًا في الصيدلية دون دفع رسوم خدمة الطوارئ الإضافية. + الأدوية الخاضعة للدفع المشترك + معفى من المشاركة + يجب على أولئك الذين لديهم تأمين صحي قانوني دفع مبلغ مشترك يصل إلى عشرة يورو للأدوية الموصوفة. \n\n يعتمد مبلغ الدفع المشترك على سعر الدواء الخاص بك. عليك أن تدفع ثمن الأدوية التي تقل تكلفتها عن 5 يورو بنفسك.\n بالنسبة للأدوية الأكثر تكلفة ، عليك أن تدفع عشرة بالمائة من السعر ، ولكن على الأقل 5 يورو و 10 يورو كحد أقصى. \n\n يُعفى الأطفال والشباب الذين تقل أعمارهم عن 18 عامًا بشكل عام من المشاركة في الدفع. \n\n إذا تجاوزت تكاليف الأدوية السنوية الخاصة بك الحد المالي الخاص بك ، فيمكن إعفائك من الدفع المشترك. تحدث إلى شركة التأمين الصحي الخاصة بك حول هذا الموضوع. + أنت معفى من الدفع المشترك لهذا الدواء. سيغطي تأمينك الصحي تكلفة الدواء. + ما هي مدة صلاحية هذه الوصفة؟ + خلال هذه الفترة ، يمكنك استبدال الوصفة الطبية الخاصة بك في أي صيدلية بدفع مبلغ إضافي. + خلال هذه الفترة ، لا يزال بإمكانك استرداد الوصفة الطبية في الصيدلية ، ولكن عليك دفع ثمن الشراء بنفسك. بدلاً من ذلك ، يمكنك أن تطلب من عيادتك إصدار الوصفة مرة أخرى. + يمكن تلقي مستحضرًا طبيًا بديلًا + نظرًا للمتطلبات القانونية لشركة التأمين الصحي الخاصة بك ، يمكن أن تحصل على بديل يحتوي على نفس العنصر النشط. \n\n يمكن أن تبدو الأدوية وتسمى بشكل مختلف ، ولها أسعار ومصنعون مختلفون ، لكنها لا تزال تحتوي على نفس العنصر النشط. العنصر النشط نفسه والجرعة مهمان بشكل خاص لتأثير الأدوية في الجسم. غالبًا ما يحصل المرضى في الصيدلية على دواء مختلف عن الدواء الموصوف من قبل الطبيب في الوصفة الطبية - بشرط أن تكون الأدوية متشابهة. يمكن أن تكون هناك أسباب علاجية واقتصادية للتغيير. + الوصفة الطبية التي تم مسحها + لأسباب أمنية ، يجب ألا تعرض الوصفات الطبية المستوردة من المطبوعات الورقية أي بيانات شخصية أو طبية. \n\n سجّل الدخول إلى هذا التطبيق باستخدام البطاقة الصحية أو تطبيق التأمين لعرض جميع المعلومات الواردة في الوصفة الطبية. + الوصفة غير صحيحة + تم إصدار هذه الوصفة بشكل غير صحيح. + الوصفة الطبية التي تم مسحها + رسوم خدمة الطوارئ + الجرعة حسب التعليمات المكتوبة + الهاتف + موقع + البريد الإلكتروني + الفرز حسب المسافة غير ممكن. + موافق + أدخل رقم التعريف الشخصي الحالي + تم إدخال رمز PIN غير صحيح + رقم التعريف الشخصي الحالي لبطاقتك الصحية + تم حظر البطاقة + قم بإلغاء حظر بطاقتك في الإعدادات > إلغاء حظر البطاقة. + لأسباب أمنية ، يرجى إدخال رقم التعريف الشخصي الحالي الخاص بك. + نسيت رقم التعريف الشخصي + وصفة غير صحيحة + الدواء + يبدو أن شيئًا ما قد حدث خطأ أثناء إعداد وصفتك. هل تريد الإبلاغ عن خطأ؟ + الإبلاغ + لم يتم التسجيل + مسجل مع + البطاقة الصحية + القياس البيومتري + لم يتم التسجيل + ونحن مهتمون في رأيك. يرجى تخصيص خمس دقائق للإجابة على استبياننا. شكرا لكم مقدما. + إشعار تحذير + تمت إضافة الصيدلة إلى المفضلة + تمت إزالة الصيدلية من المفضلة + صيدلياتي + قوة كلمة المرور جيدة جدا + عملية الكتابة غير ناجحة + تعذر حفظ رقم التعريف الشخصي + الإبلاغ + قم بتعيين PIN + انتهاك قاعدة الوصول + ليس لديك إذن للوصول إلى دليل الخرائط. + قم بتعيين رقم التعريف الشخصي الخاص بك + البطاقة مؤمنة برقم PIN من شركة التأمين الصحي الخاصة بك (رقم التعريف الشخصي للنقل) ، يرجى تعيين رقم التعريف الشخصي الخاص بك. + كلمة المرور لم يتم العثور + لا توجد كلمة مرور مخزنة على بطاقتك. + لقد تم تسجيل الخروج + سجل الدخول مرة أخرى لتحديث الوصفات الخاصة بك. + رقم العنصر النشط + الفاعلية والوحدة diff --git a/android/src/main/res/values-en/strings.xml b/android/src/main/res/values-en/strings.xml index 47e19568..c7f807b6 100644 --- a/android/src/main/res/values-en/strings.xml +++ b/android/src/main/res/values-en/strings.xml @@ -1,471 +1,769 @@ - E-prescription - OK - Cancel - Back - at - %1$s o\'clock - Last updated on %1$s - Update failed. Please update your prescriptions again. - Digital. Fast. Secure. - Welcome to the e-prescription app - Here you can redeem electronic prescriptions at a pharmacy of your choice, directly in person or online. - More features with your medical card - Automatically update your new prescriptions - Information on how to take your medication and dosages - Receive messages from your pharmacy about your order - Terms of Use & Privacy Policy - In order to use the app, please agree to the Terms of Use and confirm that you have read and understood the Privacy Policy. Only data that is essential for the functioning of the services is collected. - I have read and accept the %s. - Terms of Use - Privacy Policy - Confirm - Next - Confirm - Add prescriptions - Have you received a prescription printout? You add prescriptions to the app by scanning the respective prescription code. - Agreed - Task ID - Access code - Copied - Terms of Use - Privacy Policy - Accept Terms of Use - Accept Privacy Policy - Prescriptions - Prescriptions - Messages - Redeem - - Still valid for %s day - Still valid for %s days - - e.g. dermatologist - %s %s detected from %s %s. Scan more codes? - - Prescription - Prescriptions - - - Prescription - Prescriptions - - - Add %s prescription - Add %s prescriptions - - Access to camera denied - To use the scanner, you must allow the app to access your camera in the system settings. - Focus the camera on a prescription code - This is not a valid prescription code - This prescription code has already been scanned - - %s prescription recognised - %s prescriptions recognised - - Cancel - Camera light - Cancel scanning of prescription codes? - Cancel scanning - Continue - Add card - Let\'s get started - Use all functions now - To be able to use all functions of the app, log in with your medical card. You will receive this card and the required login details from your health insurance company. - What you need: - A medical card with access number (CAN) - The PIN for the medical card - Add card - What a pity ... - Unfortunately, your device does not meet the minimum requirements for logging on to the e-prescription app. - Why are there minimum requirements for logging on with your medical card? - Your card access number (CAN) has six digits. You will find the CAN in the top right-hand corner of the front of your medical card. If there is no six-digit access number here, you will need a new medical card from your health insurance company. - Enter access number - You can enter any digits. - Your PIN can have between 6 and 8 digits. - Enter PIN - In demo mode, you can enter any PIN. - Try again - Have your electronic medical card to hand. - The time it takes for your device to connect to the server can vary depending on the hardware and Internet speed. - Failed to connect to the server. - Check your connection to the Internet and start the process again. - Incorrect PIN entered. - - You have %s attempt remaining before your card is locked. - You have %s attempts remaining before your card is locked. - - Incorrect CAN entered - You will find the access number in the top right-hand corner of your medical card. - PIN was entered incorrectly several times. - Your medical card needs to be unlocked with the PUK. - Cancel - Searching for card... - Hold the medical card to the back of the device - Still searching ... - Slowly move the card on the back of the device. - Tip - Device covers may make it difficult to connect via NFC. - Card recognised - Try not to move the medical card. - Medical card found. Please do not move. - Connection interrupted - Hold your medical card to the back of the device again - You have successfully logged in - Note: only prescriptions from the last 100 days are downloaded. - Demo mode enabled - Do you have an NFC-enabled medical card and want to try it out in demo mode? - Continue with card - Continue without a card - Demo mode enabled - Version: %s - Build hash: %s - Debug menu - Prescription code - Have this prescription code scanned at your pharmacy. - This group code combines %s prescriptions - Redeem at pharmacy - You are in a pharmacy and want to redeem your prescription. - Order or reserve - Submit your prescription to a pharmacy and decide how you would like to receive your medication. - You will need a valid medical card for this. - Select pharmacy - e.g. Penguin Pharmacy or address - Find pharmacies easily - Share location and find pharmacies in your area - Share location - Open until %s o\'clock - Open continuously - Imprint - Publisher - gematik GmbH\nFriedrichstr. 136\n10117 Berlin, Germany - Managing Director: Dr. med. Markus Leyck Dieken\nRegister Court: District Court of Berlin-Charlottenburg\nCommercial register no.: HRB 96351\nVAT ID: DE241843684 - Responsible for the content - Dr. med. Markus Leyck Dieken - Contact - Note - We strive to use gender-sensitive language. If you notice any errors, we would be pleased to hear from you by email. - Scanned prescription - Medicine %s - Current - Update - Archive - You haven\'t redeemed any prescriptions yet - - %s medicine - %s medicines - - Redeemed on %s - You haven\'t redeemed any prescriptions yet - Germany\'s modern platform for digital medicine - Write email - Open website - Welcome - Start login - Press Unlock - Unlock - Do you have any questions or problems concerning use of the app? You can contact our technical hotline on %s. We have already answered plenty of questions for you at %s. - Log in - Cancel - https://www.das-e-rezept-fuer-deutschland.de/ - das-e-rezept-fuer-deutschland.de - Settings - Name unknown - Medical cards - Add card - To try out - Demo mode allows you to explore all areas of the app even without an electronic medical card. - Demo mode - Security - Protect your health information from unauthorised access. - Do not secure - Not recommended - Biometrics - This app uses the most secure biometric sensor provided by your device. - Device security - Not recommended - Legal information - Imprint - Privacy Policy - Terms of Use - Demo mode enabled - Our demo mode shows you all the functions of the app – without a medical card. - Would you like a tour of the app? - Our demo mode shows you all the functions of the app – without a medical card. - Launch demo mode - You do not have any current prescriptions - Secure prescription data - Improved protection of your data with a fingerprint or face scan. - Enable now - Details - Keep track of things - Mark this prescription as redeemed as soon as you have received your medication. - Automatically update prescriptions - Log in so that your prescriptions can be automatically marked as redeemed. - Log in now - Why am I only seeing this information? - Your health information is subject to special protection - Medicine %1$d - Mark as redeemed - Mark as not redeemed - Delete from this device - Log - Scanned on - %1$s o\'clock - Can still be redeemed until %s - Details about this medicine - Dosage form - Package size - Pharma central number (PZN) - Directions for use - Please follow the directions for use in your medication schedule or the written dosage instructions from your doctor. - Insured person - Name - Address - Date of birth - Health insurance / cost unit - Status - Insurance number - Prescriber - Name - Specialist physician - Physician number (LANR) - Institution - Name - Address - Establishment number - Telephone number - Email - Accident at work - Date of accident - Accident company or employer number - Do you want to permanently delete this prescription? - Delete - Cancel - Do you want to make just this prescription available again or all prescriptions? - All - Just this one - This is a matter of urgency - This medication can also be redeemed in a pharmacy at night without an emergency service fee. - Substitute medication possible - Substitutes are permitted. You may be given an alternative due to the legal requirements of your health insurance. - Make a binding reservation - Request delivery service - Delivery by mail order - Please note that prescribed medication may also be subject to additional payments. - Opening hours - Website - Reservation - Redeem the following prescriptions with binding effect at %s? - Prescriptions - Redeem - Delivery service - Delivery address - How can we help you? - Has the delivery address changed? Is there anything else you would like to tell the pharmacy? - Call now - You can change your delivery address on the website of the mail-order pharmacy. - Mail order - Log - without stored action - expired - only valid today - Rename prescription block - You can assign a name for this prescription block. - Log in - Log in - An NFC-enabled smartphone with at least Android 7 - Enable NFC - Please enable the NFC function on your device to log in with your medical card. - Enable - How do I get a new medical card? - Your health insurance company will be able to help you with this. - How do I get a PIN? - You will receive a PIN for your medical card in a separate letter from your health insurance company. - Would you like to save your login details for future logins? - Save login details - Convenient: your data will be biometrically protected on the device for this purpose - Security not possible - No secure sensors available or biometric security not set up. - Do not save login details - Data-efficient: Requires you to enter your login details each time you launch the app - Correct - To the homepage - Marked as redeemed on - Marked as not redeemed on - Display as single codes - Display as group code - %s of %s - Prescriptions redeemed? - Would you like to mark the prescriptions as redeemed? - Not redeemed - Redeemed - Opens at %s o\'clock - +49 800 277 377 7 - Technical hotline - Open scanner for prescriptions - Settings - +49 800 277 377 7 - Please identify yourself via fingerprint or facial recognition. - Note - This change will only take effect after the app is restarted. - OK - Tracking - Help us make this app better. All user data is collected anonymously and is used solely to improve the user experience. - Allow tracking - In the event of a crash or an error in the app, the app sends us information about the reasons along with the operating system version and details of the hardware used. - Suppress screenshots - Prevents the display of a preview image when switching apps - Do you consent to the anonymous analysis of usage behaviour by e-prescription? - This includes information about your phone\'s hardware and software, settings of the e-prescription app as well as the extent of use, but never any personal or health data concerning you. \nThis data is made available exclusively to gematik GmbH by data processors and is deleted after 180 days at the latest. You can disable the analysis of your usage behaviour at any time in the settings menu of the app.\nWe can use this data to understand which functions are used frequently and improve them. Furthermore, we can assess how long older technology needs to be supported and when we can, for example, make a newer operating system version mandatory without affecting (too many) users. - Allow - - You have been prescribed %s medicine - You have been prescribed %s medicines - - Tap here to redeem at a pharmacy - Redeem now - Show all - Delete regulation - Mark as redeemed - Undo - Show more - Show less - Technical information - Log out - All access data to the health network will be deleted. Your prescription data will be retained. - This will delete your login details. - Log out - Cancel - Would you like to log out of the app? - Security of your prescription data - Please be aware that people with whom you may share this device and whose biometrics may be stored on this device or who have the device PIN, swipe pattern or password may also have access to your prescriptions. - Redeem with binding effect? - Your prescriptions will be sent to this pharmacy. You will then not be able to redeem them in any other pharmacy. - Cancel - Redeem now - Successfully redeemed - The pharmacy will contact you as soon as possible to verify the delivery details with you. - Complete your order in the browser - Go to homepage - The mail-order pharmacy will create a shopping cart for you with your medicines. This process may take a few minutes. - Tap on \"Open shopping cart\" and complete your order on the pharmacy\'s website. - To the homepage - Sending failed - Repeat - Your order will usually be ready for you as soon as possible. Please contact the pharmacy for exact timings. - Your shopping cart is ready - Collection code received - Message received - Show collection code - Open shopping cart - Show this code at your pharmacy. - Collection code - No messages - You haven\'t received any messages yet - Unfortunately, your pharmacy\'s message was empty. Please contact your pharmacy. - No email program set up - No results - We couldn\'t find any results with this search term. - Open source licences - Contact - Call technical hotline - Write email - Take part in the survey - +49 800 277 377 7 - Find out more - Smiling family - Pharmacist holds a smarthone and is looking forward to serving you. - Hand holds a smartphone and is authenticating itself in the app using the new electronic medical card - Help us to improve the app - We want: - Analyse user flows in the %s app to improve usability. - Send crashes and error messages %s to the developers. - Detect error patterns at an early stage to improve the technical hotline. - I would like to help improve the app - You can modify this decision in the system settings at any time - Anonymous - Next - This comprises the hardware and software information of your phone, e-prescription app settings and the extent of use, but never your personal or health data. - The data is provided exclusively by data processing providers to gematik GmbH and deleted after a maximum of 180 days. You can disable the analysis again at any time via the menu in the app. - We can use this data to understand which functions are used frequently and improve them. Furthermore, we can assess how long older technology needs to be supported and when we can, for example, make a newer operating system version mandatory without affecting (too many) users. - Improve app - Reject - Anonymous analysis remains disabled - %s Thank you for your support! - Order or reserve - Prescriptions are automatically marked as redeemed - There may be a delay before redeemed prescriptions are displayed in the \"Archive\" area. - OK - You need to be logged on to delete prescriptions. - Report error - Defective message received - A pharmacy has sent a message in an incorrect format. - Error message from the e-prescription app - You are sending us this information for purposes of troubleshooting. Please note that your email address and any name you include will also be transferred. If you do not wish to transfer this information either in full or in part, please remove it from this email. \n\nAll data will only be stored or processed by gematik GmbH or its appointed companies in order to deal with this error message. Deletion takes place automatically a maximum of 180 days after the ticket has been processed. We will use your email address exclusively to contact you regarding this error message. If you have any questions, or require an earlier deletion, you can contact the data protection representative responsible for the e-prescription system. You can find further information in the menu below the entry for data protection in the e-prescription app. - Log in - Please identify yourself in order to download prescriptions. - Redeemed on %s - You have received a substitute medication - Please follow the directions for use in your medication schedule or the written dosage instructions from your doctor. - Note to pharmacies: this app obtains the contact details for and information about pharmacies from mein-apothekenportal.de provided by the Deutscher Apothekenverband e.V. Have you found an error or would you like to correct any data? - Find out more - Pharmacies - Unfortunately that didn\'t work \uD83D\uDE15 - Please try again. - Do you have any questions or problems concerning use of the app? You can contact our technical hotline on %s. - We have already answered plenty of questions for you at %s. - Enter password - Next - Accessibility aids - Zoom - Enables the app to be zoomed in/out by moving fingers together or apart on the screen (pinch-to-zoom). - Note - Password - Secure your data with a password of your choice. - Password - Save - Show password - Enter password - You can use any digits, letters or special characters. - Repeat password - Password strength - Recommendations: %s - Write email - We look forward to your feedback - The more specific, the better - The following information about the hardware and operating system you use is transferred when you send an email: - Operating system - Android %s (developer version %s) (latest security update %s) - Model - %s %s (code name %s) - Mode - Dark mode - Light mode - Language - Send - Feedback - Medical card - Agreed - Apply for new medical card - This app helps you to apply for a new electronic medical card. You will not be charged for this. - Can be redeemed soon - This pharmacy is not yet able to receive any e-prescriptions. - E-prescription - Ready for the e-prescription - Currently open - Delivery service - Mail order - Filter - Favourite filters - Filter - Location sharing may be disabled in the settings. - No location available - Health insurance provider - Insurance number - Send email - Select health insurance company - Please review your input - Insurance number + E-prescription + OK + Cancel + Back + at + Digital. Fast. Secure. + Add prescriptions + Have you received a prescription printout? You add prescriptions to the app by scanning the respective prescription code. + Agreed + Task ID + Access code + Terms of Use + Privacy Policy + Prescriptions + Access to camera denied + To use the scanner, you must allow the app to access your camera in the system settings. + Focus the camera on a prescription code + This is not a valid prescription code + This prescription code has already been scanned + + %s prescription recognised + %s prescriptions recognised + + Cancel + Camera light + Cancel scanning of prescription codes? + Cancel scanning + Continue + Let\'s get started + What you need: + Enter access number + Enter PIN + Try again + Failed to connect to the server. + Incorrect PIN entered. + + You have %s attempt remaining before your card is locked. + You have %s attempts remaining before your card is locked. + + Incorrect CAN entered + You will find the access number in the top right-hand corner of your medical card. + Cancel + Searching for card... + Hold the medical card to the back of the device + Still searching ... + Slowly move the card on the back of the device. + Tip + Device covers may make it difficult to connect via NFC. + Card recognised + Try not to move the medical card. + Medical card found. Please do not move. + Connection interrupted + Hold your medical card to the back of the device again + Version: %s + Build hash: %s + Debug menu + Prescription code + Have this prescription code scanned at your pharmacy. + This group code combines %s prescriptions + Redeem at pharmacy + You are in a pharmacy and want to redeem your prescription. + Order or reserve + Submit your prescription to a pharmacy and decide how you would like to receive your medication. + Share location and find pharmacies in your area + Share location + Open until %s o\'clock + Open continuously + Imprint + Publisher + gematik GmbH\nFriedrichstr. 136\n10117 Berlin, Germany + Managing Director: Dr. med. Markus Leyck Dieken\nRegister Court: District Court of Berlin-Charlottenburg\nCommercial register no.: HRB 96351\nVAT ID: DE241843684 + Responsible for the content + Dr. med. Markus Leyck Dieken + Contact + Note + We strive to use gender-sensitive language. If you notice any errors, we would be pleased to hear from you by email. + Germany\'s modern platform for digital medicine + Write email + Open website + Welcome + Start login + Unlock + Log in + Cancel + Security + Legal information + Imprint + Privacy Policy + Terms of Use + Details + Mark as redeemed + Mark as not redeemed + Dosage form + Standard size + Insured person + Name + Address + Date of birth + Health insurance / cost unit + Status + Insurance number + Prescriber + Name + Specialist physician + Physician number (LANR) + Institution + Name + Address + Establishment number + Telephone number + Email + Accident at work + Date of accident + Accident company or employer number + Do you want to permanently delete this prescription? + Delete + Cancel + Substitutes are permitted. You may be given an alternative due to the legal requirements of your health insurance. + Make a binding reservation + Request delivery service + Delivery by mail order + Please note that prescribed medication may also be subject to additional payments. + Opening hours + Website + Redeem the following prescriptions with binding effect at %s? + Can only be redeemed today as self-paying customer + Log in + Enable NFC + Please enable the NFC function on your device to log in with your medical card. + Enable + Correct + Display as single codes + Display as group code + %s of %s + Prescriptions redeemed? + Would you like to mark the prescriptions as redeemed? + Not redeemed + Redeemed + Opens at %s o\'clock + +49 800 277 377 7 + Technical hotline + Open scanner for prescriptions + Settings + Note + This change will only take effect after the app is restarted. + OK + Suppress screenshots + Prevents the display of a preview image when switching apps + Do you consent to the anonymous analysis of usage behaviour by e-prescription? + Technical information + Security of your prescription data + Please be aware that people with whom you may share this device and whose biometrics may be stored on this device or who have the device PIN, swipe pattern or password may also have access to your prescriptions. + Sending failed + No email program set up + No results + We couldn\'t find any results with this search term. + Open source licences + Contact + Call technical hotline + Take part in the survey + +49 800 277 377 7 + Find out more + I would like to help improve the app + This comprises the hardware and software information of your phone, e-prescription app settings and the extent of use, but never your personal or health data. + The data is provided exclusively by data processing providers to gematik GmbH and deleted after a maximum of 180 days. You can disable the analysis again at any time via the menu in the app. + We can use this data to understand which functions are used frequently and improve them. Furthermore, we can assess how long older technology needs to be supported and when we can, for example, make a newer operating system version mandatory without affecting (too many) users. + Improve app + Anonymous analysis remains disabled + %s Thank you for your support! + Note + There may be a delay before redeemed prescriptions are moved to the archive. + OK + Log in + Please identify yourself in order to download prescriptions. + Redeemed on %s + Note to pharmacies: we obtain the contact details for and information about pharmacies from mein-apothekenportal.de provided by the Deutscher Apothekenverband e.V. Have you found an error or would you like to correct any data? + Find out more + Pharmacies + Unfortunately that didn\'t work \uD83D\uDE15 + Please try again. + Enter password + Next + Accessibility aids + Zoom + Enables the app to be zoomed in/out by moving fingers together or apart on the screen (pinch-to-zoom). + Note + Password + Secure your data with a password of your choice. + Password + Save + Show password + Repeat password + Recommendations: %s + Write email + The following information about the hardware and operating system you use is transferred when you send an email: + Can be redeemed soon + This pharmacy is not yet able to receive any e-prescriptions. + Currently open + Delivery service + Mail order + Filter + Filter + No location available + Agreed + Repeated password matches + + Can still be redeemed for %s day as a self-paying customer + Can still be redeemed for %s days as a self-paying customer + + + Still valid for %s day + Still valid for %s days + + Open scanner + We process your device information!\nThis app uses the ML Kit from Google to read the prescription code. By clicking \"Accept\", you agree to Google occasionally accessing device information and processing it for the purpose of usage analysis, diagnostics and configuration of the ML Kit. You have the right to revoke your consent at any time, although this will not affect the lawfulness of any processing already performed. However, such a revocation will mean that the prescription code scanner cannot be used. + Agreed + Cancel + Error 20 10 76631 + Your medical card\'s certificate is invalid. Your card may have expired. Please contact your health insurance company. + Unsuccessful login attempts + + %s unsuccessful login attempt detected. + %s unsuccessful login attempts detected. + + Select optimum device security + This may be a fingerprint, swipe pattern or similar + Tokens + Access token + SSO token + No access token available + no SSO token available + copied to the clipboard + Click to copy the token to the clipboard + Validity ends today + Allow + No connection to the server + Please try again in a few minutes. + Reload + Show tokens + How would you like to secure this app? + Note + No device security has been set up for this device + We recommend that you add additional protection for your medical data by securing your device for instance with a code or biometrics. + Do not show this message in future. + Connection failed. A network connection could not be created. + Communication with the server failed: status code %s. + Communication with the server failed: VAU error + Warning + This device may not be fully trustworthy + This app should not be used on rooted devices for security reasons. + I acknowledge the increased risk and would like to continue anyway. + Why are devices with root access a potential security risk? + Find out more + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + Profile name + Please enter a name for the new profile. + Profile name + Profiles + Add profile + Medical card + Contact health insurance company + You can use an NFC-enabled medical card and the associated PIN to log into this app. + You can obtain one free of charge from your health insurance company. You need to provide an official form of identification as proof of identity. + How to identify an NFC-enabled medical card + Select health insurance company + No selection + What would you like to apply for? + Contact is not possible via this app + Please contact your health insurance company via the usual channels. + Medical card & PIN + PIN only + Contact your health insurance company + Log in to e-prescription app + The name field cannot be empty. + A profile with this name already exists. + Profile + %s selected + Background colour + Spring grey + Sun dew + It! Is! Pink! + Tree + Blue moon September + Not logged in + Connected + Last connected on %s + Delete profile? + All data belonging to the profile will be deleted on this device. Your prescriptions in the health network will be retained. + Delete + Cancel + Delete profile + You would like to delete the last profile. + The app requires at least one profile. Please enter a name for the new profile. + Error 20 10 76831 + The register of medical cards could not be reached. Please try again. + You can find professionally verified information on illnesses, ICD codes and issues around prevention and healthcare in the National Health Portal. + Open gesund.bund.de + We have amended the Privacy Policy + The e-prescription app has evolved, so we have had to update our Privacy Policy. + Open Privacy Policy + This has changed since %s: + What happens when you open the app? + What happens if I use the camera function/read prescriptions using the camera? + Select profile + Edit profiles + No new prescriptions available + + %s prescription updated + %s prescriptions updated + + Can be redeemed + Being redeemed + Redeemed + Unknown + Details + Show access logs + Here you can see who has accessed your prescriptions + This relates to access keys for the prescription service + Access logs + No access logs + Log into the prescription service to receive access logs. + No access logs are available yet. + Last updated on %s + The prescription is currently being processed and cannot be deleted + Accept + That didn\'t seem to work + We are aware that connecting using your medical card has its quirks. For that reason, in future it should be possible to log in using a health insurance company\'s app that has previously been authenticated.\n\nWe are also working on enabling prescriptions to be redeemed digitally without the need to log in.\n\nDid you notice anything during this process that you would like to share with us? We look forward to even very critical feedback. + Connection tips + Increase the strength of the connection + Remove the protective case if necessary. + If the device vibrates and then breaks off the connection, look for the ideal position within a small radius. + Only move the device very slowly over the card. + Place the device directly on to the card. + You can do so by placing the medical card on an even surface (e.g. a table). + Increase the strength of the connection + Note the position of the NFC sensor + Find out where the NFC sensor is on your device (for example, this is an overview of devices by %s). + In some cases the position of the NFC sensor may vary within a model series (these are the details for example for the %s). + Next tip + Next + Close + Try out + Contact us + Licence pharmacy search + Redeem + Scanned prescription + Scanned on %s + Marked as redeemed on %s + How would you like to continue? + Order + Available soon + Reserve now for collection or for delivery by courier or mail order + Save to order later on + Save prescriptions on device + + Continue with %s prescription + Continue with %s prescriptions + + Failed to connect medical card + The current profile is already connected to a different medical card (health insurance number %s). + Your medical card is already connected to a different profile. Switch to profile %s. + My order + Reserve now + Order now + Save + Contact details and address + Contact + Telephone number + Please enter a telephone number for contact purposes. + Email address (optional) + Delivery address + First name and surname + Please enter a first name and surname for contact purposes. + Street and house number + Please enter a street and house number for contact purposes. + Additional address line (optional) + Postcode and town/city + Please enter a postcode and town/city for contact purposes. + Delivery instruction (optional) + Your prescription will be sent to this pharmacy. You will then not be able to redeem it in any other pharmacy. + Contact details and delivery address + Prescriptions + We need your contact details in order for the pharmacy to be able to advise you and let you know the current status of your order. + Enter contact details + More contact details required + Order successfully sent. + Your pharmacy will contact you soon. + Close + Discard changes? + Discard + Searches with the pharmacy directory use geocoordinates provided with the assistance of OpenStreetMap. We thank this project for their help. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + Usage & Privacy Policy + Next + You will have received your PIN in a letter from your health insurance company. + PIN not received + PIN + Check your connection to the Internet and your device\'s time/date setting. + Press \"Unlock\" to authenticate yourself. + Locked out? Please review your biometric access data on this device. + Forgotten password? Please delete and reinstall the app. Find out why in our %s. + Help zone + Quantity and unit + Active substance + Active substance quantity + Batch description + Use by + Category + Vaccine + Accept + Undo + Note + Would you like to help us improve the app? + Select own password + The password needs to be at least eight characters long + Password strength not sufficient + Password strength sufficient + Password is visible + Password is not visible + Biometrics + Password + Waiting for response + No prescriptions + You do not currently have any prescriptions that can be redeemed. + Update + Automatic log-off + Your will be disconnected from the prescription server 12 hours for security reasons. Reconnect to retrieve current prescriptions. + Connect + Have you received a paper print-out? + Add prescriptions to your list by tapping the scan button in the top right corner. + Scan paper print-out + You need to be logged in to receive prescriptions automatically. + Log in + No redeemed prescriptions + Your redeemed prescriptions are displayed here. Your prescriptions will be deleted from the prescription server after 100 days on data protection grounds. + No redeemed prescriptions + Your redeemed prescriptions are displayed here. Scan new prescriptions to start the redemption process. + Device management + Connected devices + Registered since %s (this device) + Registered since %s + Current + Archive + Redeem again? + Note: The first pharmacy to accept a prescription blocks it for processing by another pharmacy. + Cancel + OK + + You have already sent prescription %s to a pharmacy. Are you sure you want to redeem it again? + You have already sent some of these prescriptions to a pharmacy. Are you sure you want to send them to another pharmacy? + + For security reasons, the connection to the recipe server is terminated after 12 hours. To reconnect, you need a health card and PIN for each connection process. + PIN + Enter your PIN (medical card). + Next + Authentication + Connected devices + Remove device? + Cancel + Remove + Remove this device? + What would you like to remove %s? + If you remove %s, the connection to the prescription server will be permanently cut off in a maximum of 12 hours. + Loading devices... + No devices + There are no devices associated with this medical card. + Try again + Uh oh :-( + Device list could not be loaded. + No connection + No Internet connection. + Medicines and dressings + Narcotics + Issue of prescription-only medicines as per section 4 AMVV + Do you need any help? + We have put a few tips together for you to solve the most common problems. + Launch connection tips + Scanned on: %s + Scanned prescription + Unlock + Card blocked + The PIN was entered incorrectly three times. Your card has therefore been blocked for security reasons. + Unlock card + Enter PUK + You will have received an 8-digit PUK along with your PIN from your health insurance company. + Select new PIN + You can choose your new personal identification number (PIN) with 6 to 8 digits. + PIN remembered? + Please make a note of your PIN and keep it in a safe place. + Cancel + Incorrect PUK entered. + OK + Cannot unlock + You\'ve used this PUK to unlock your card the maximum number of times or have repeatedly entered it incorrectly. Please contact your health insurance company. + You can use a PUK to unlock up to 10 times. + Card unlocked + What you need: + Your medical card + Medical card PUK + Next + Medical card + Order new card + Log in + Receive prescriptions online and forward them to a pharmacy. + NFC-enabled medical card + Medical card PIN + Don\'t have an NFC-enabled medical card and PIN yet? + Order now + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + Or: Sign in with your %s. + health insurance company app + "Your card access number (CAN) is located in the top right-hand corner on the front of your medical card." + My medical card has no access number + + You have %s more attempt before your card is blocked. + You have %s more attempts before your card is blocked. + + Place medical card on the back of the phone + The following process can take up to 30 seconds. + Place card %s on the back of the phone. + in the upper right area + in the upper central area + in the upper left area + in the central right area + centrally + in the central left area + in the lower right area + in the lower central area + in the lower left area + Help + Sent %s minutes ago + Sent on %s + Sent just now + Sent at %s o\'clock + No longer valid + Log in with app + Select insurance company + Didn\'t find what you were looking for? This list is constantly being expanded. Login with a medical card is already supported by every health insurance company. + Feedback from the e-prescription app + We look forward to your feedback. Please use the space below and word your comments as precisely as possible: + PUK + Close + What a pity... + Unfortunately, your device does not meet the minimum requirements for logging into the e-prescription app. For secure authentication with your medical card, at least Android 7 and an NFC chip are required. + Find out more + Save login details? + Save + Do not save + Note + For security reasons, the connection to the recipe server is terminated after 12 hours. To reconnect, you need a health card and PIN for each connection process. + Set up biometric protection + Access data cannot be saved. Set up biometric protection (e.g. fingerprint) on your device beforehand. + Cancel + Settings + Note + Accept + Security of your prescription data + \"This app uses the most secure biometric sensor provided by your device to store your access data in the secure area of the device memory. \" + Using biometric protection for your access data means that you can launch this app in future without your medical card and PIN in order to view, retrieve, redeem or delete prescriptions. + Please be aware that people with whom you may share this device and whose biometrics may be stored on this device or who have the device PIN, swipe pattern or password may also have access to your prescriptions. + Unfortunately that didn\'t work + Authentication with the health insurance company\'s app was successful. + Expired on %s + The prescription has already been deleted from the server + Please correct your input or discard changes + Correct + Policyholder details + Name + Insurance + Insurance number + Access number + Log in + Log out + You need to be logged in to receive prescriptions automatically. + Save + Change + Edit profile picture + Next + Server not responding + Please try again later. + Try again + Look for insurance + Connect to the prescription server now? + Logged in successfully + connection lost + Connect to the prescription server now? + No tokens + You will receive a token when you are logged in to the prescription service.\n + orders + Select desired PIN + Unlock card + Choose PIN + Repeat PIN + The entries differ from each other. + No orders + You don\'t have any orders yet. + Just now + At %s o\'clock + Shopping cart is ready + The prescription has been added to your shopping cart. Please go to the pharmacy\'s website to complete the order. + Open shopping cart + Show this pick-up code at the pharmacy. + Receive pickup code + Message cannot be displayed + Please contact your pharmacy ( %s ). + Show cart link + Show pickup code + Show the message + %s at %s o\'clock + Prescriptions sent to %s . + Order overview + New + Course + an order + Free for the caller. Service times: Mon - Fri 8:00 a.m. - 8:00 p.m. except on national holidays + Pharmacy + Select desired PIN + Desired PIN saved + It is not possible to save the desired PIN + E-prescription + Currently open and near me + Filter by … + start search + Cancel + Will be redeemed for you + direct assignment + Redeem + phone number (optional) + Search for name or address + No valid pharmacy information + No current information was found about this pharmacy. The entry for this pharmacy will be deleted. + OK + Pharmacy directory not available + Currently no current information about this pharmacy can be retrieved. Please check your internet connection. + Cancel + Try again + Save Environment + Sign-in not possible + It appears that your biometric login credentials have changed. Please register again with your health card. + Cancel + Log in + profile 1 + Close to me + They have no redeemable prescriptions + Redeemable later + Redeemable from %s + %s / %s + product improvements + Anonymous Analysis + Help us make this app better. All user data is collected anonymously and is only used to improve the user experience. + device security + personal settings + Accessibility aids + product improvements + Added prescription + Prescription already imported + An error occurred while importing + Delete + Scanned prescription + Substitute medication possible + Sent on %s at %s + Redeemed on %s at %s + Forgot PIN + + %s Recipe + %s Recipes + + I have read and accept the privacy policy and terms of use. + Privacy Policy + Terms of Use + We would like: + Improve usability. + Detect errors and crashes. + All data is of course collected anonymously. + You can modify this decision in the system settings at any time + Continue + Accept + This app uses the most secure method provided by your device. + Save + Choose + Medicine + trade name + Yes + no + dosage + date of issue + noctu + This prescription will be redeemed for you as part of a treatment. + Not specified + No emergency service fee + additional payment + Medicine + Delivery Notes + Eligible according to BVG + alternative preparation + recipe name + Packaging + crafting instruction + description + given by + issued on: + Active substance + prescribed + Receive + What is a direct assignment? + In the case of direct referrals, a prescription from a practice or hospital is redeemed directly at a pharmacy. Insured persons do not have to take any action and cannot intervene in the redemption process. \n\n Direct referrals are listed in the e-prescription app to make your treatment more transparent for you. + No emergency service fee + Hurry is the order of the day here. This prescription can also be filled at night in a pharmacy without the additional payment of an emergency service fee. + Drugs subject to co-payment + Exempted from co-payment + Those with statutory health insurance must pay a co-payment of up to ten euros for prescription drugs. \n\n The amount of the co-payment depends on the price of your medication. You have to pay for medicines that cost less than €5 yourself.\n For medicines that are more expensive, you have to pay ten percent of the price, but at least €5 and a maximum of €10. \n\n Children and young people under the age of 18 are generally exempt from co-payment. \n\n If your annual costs for medication exceed your financial limit, you can be exempted from the co-payment. Talk to your health insurer about this. + You are exempt from the co-payment of this drug. Your health insurance will cover the cost of the medication. + How long is this prescription valid? + During this period, you can redeem your prescription in any pharmacy with an additional payment. + During this period, you can still redeem the prescription in a pharmacy, but you have to pay the purchase price yourself. Alternatively, you can ask your practice to issue the prescription again. + Substitute medication possible + Due to the legal requirements of your health insurance company, you can be given an alternative with the same active ingredient. \n\n Medicines can look and be called differently, have different prices and manufacturers, but still contain the same active ingredient. The active ingredient itself and the dosage are particularly important for the effect of drugs in the body. Patients in the pharmacy often get a different drug than the one prescribed by the doctor on the prescription - provided the drugs are comparable. There can be therapeutic and economic reasons for the change. + Scanned prescription + For security reasons, prescriptions imported from a paper printout must not display any personal or medical data. \n\n Sign in to this app with health card or insurance app to view all information contained in the prescription. + Recipe incorrect + This prescription was issued incorrectly. + Scanned prescription + emergency service fee + Dosage according to written instructions + Phone + site + Email + Sorting by distance not possible. + OK + Enter current PIN + Incorrect PIN entered + The current PIN of your health card + Card blocked + Unblock your card in Settings > Unblock card. + For security reasons, please enter your current PIN. + Forgot PIN + Defective prescription + Medicine + Something seems to have gone wrong when creating your prescription. Report an error? + Report + Not logged in + Registered with + Medical card + Biometrics + Not logged in + We are interested in your opinion. Please take five minutes to answer our survey. Thank you in advance. + warning notice + Pharmacy added to favorites + Removed Pharmacy from Favorites + My pharmacies + Password strength very good + Write operation unsuccessful + PIN could not be saved + Report + Assign PIN + Access rule violated + You do not have permission to access the map directory. + Assign your own pin + The card is secured with a PIN from your health insurance company (transport PIN), please assign your own PIN. + Password not found + There is no password stored on your card. + You have been logged out + Sign in again to update your recipes. + active ingredient number + potency and unity diff --git a/android/src/main/res/values-pl/strings.xml b/android/src/main/res/values-pl/strings.xml index d7cdc46c..fdbd8d99 100644 --- a/android/src/main/res/values-pl/strings.xml +++ b/android/src/main/res/values-pl/strings.xml @@ -1,487 +1,789 @@ - E-recepta - OK - Anuluj - Powrót - o - godz. %1$s - Ostatnio zaktualizowano dnia %1$s - Aktualizacja się nie powiodła. Zaktualizuj recepty ponownie. - Elektronicznie. Szybko. Bezpiecznie. - Witamy w aplikacji E-recepta - Tutaj możesz zrealizować recepty elektroniczne w wybranej aptece, bezpośrednio na miejscu lub online. - Więcej funkcji z kartą zdrowia. - Automatycznie aktualizuj swoje nowe recepty - Informacje na temat przyjmowania i dawkowania Twoich leków - Otrzymuj powiadomienia z apteki dotyczące Twojego zamówienia - Warunki korzystania i polityka prywatności - Aby móc korzystać z aplikacji, musisz zaakceptować warunki korzystania i potwierdzić zaznajomienie się z polityką prywatności. Zapisywane są tylko dane niezbędne do realizacji usług. - Zapoznałem(am) się z %s i akceptuję je. - Warunki korzystania - Polityka prywatności - Potwierdź - Dalej - Potwierdź - Dodaj recepty - Otrzymałeś(aś) wydruk recepty? Możesz dodać receptę do aplikacji, skanując jej kod. - Rozumiem - ID zadania - Kod dostępu - Skopiowano - Warunki korzystania - Polityka prywatności - Akceptuj warunki korzystania - Akceptuj politykę prywatności - Recepty - Recepty - Powiadomienia - Zrealizuj - - Ważna jeszcze przez %s dzień - Ważna jeszcze przez %s dni - Ważna jeszcze przez %s dni - Ważna jeszcze przez %s dni - - np. dermatolog - Rozpoznano %s %s z %s %s. Czy chcesz skanować następne kody? - - recepta - recepty - recept - recept - - - recepta - recepty - recept - recept - - - Dodaj %s receptę - Dodaj %s recepty - Dodaj %s recept - Dodaj %s recept - - Odmowa dostępu do kamery - Aby móc użyć skanera, musisz w ustawieniach systemowych zezwolić aplikacji na dostęp do kamery. - Ustaw kamerę nad kodem recepty - Kod recepty jest nieprawidłowy - Ten kod recepty został już zeskanowany - - %s recepta rozpoznana - %s recepty rozpoznane - %s recept rozpoznanych - %s recept rozpoznanych - - Anuluj - Światło kamery - Czy anulować skanowanie kodów recept? - Anuluj skanowanie - Kontynuuj - Dodaj kartę - Zaczynamy - Korzystaj ze wszystkich funkcji - Aby móc korzystać ze wszystkich funkcji aplikacji, zaloguj się za pomocą swojej karty zdrowia. Kartę tę oraz wymagane dane dostępowe otrzymasz od swojej instytucji ubezpieczenia zdrowotnego. - Co jest potrzebne: - Karta zdrowia z numerem dostępu (CAN) - PIN do karty zdrowia - Dodaj kartę - Szkoda... - Niestety Twoje urządzenie nie spełnia warunków minimalnych do zalogowania w aplikacji E-recepta. - Dlaczego istnieją warunki minimalne do zalogowania za pomocą karty zdrowia? - Twój numer dostępu karty (Card Access Number, w skrócie: CAN) ma 6 znaków. Numer CAN znajdziesz w prawym górnym rogu z przodu karty zdrowia. Jeżeli nie ma tam sześcioznakowego numeru dostępu, musisz otrzymać nową kartę zdrowia od swojej instytucji ubezpieczenia zdrowotnego. - Wprowadź numer dostępu - Możesz podać dowolne cyfry. - Twój PIN może mieć od 6 do 8 znaków. - Wprowadź PIN - W trybie demonstracyjnym możesz wprowadzić dowolny PIN. - Spróbuj ponownie - Przygotuj swoją elektroniczną kartę zdrowia. - Czas trwania połączenia Twojego urządzenia z serwerem może być różny w zależności od sprzętu i prędkości Internetu. - Nie udało się utworzyć połączenia z serwerem. - Sprawdź swoje połączenie z Internetem i rozpocznij operację ponownie. - Wprowadzono błędny PIN. - - Masz jeszcze %s próbę, zanim Twoja karta zostanie zablokowana. - Masz jeszcze %s próby, zanim Twoja karta zostanie zablokowana. - Masz jeszcze %s prób, zanim Twoja karta zostanie zablokowana. - Masz jeszcze %s prób, zanim Twoja karta zostanie zablokowana. - - Wprowadzono błędny CAN - Numer dostępu znajduje się na górze z prawej strony Twojej karty zdrowia. - Wielokrotnie wprowadzono błędny PIN. - Twoja karta zdrowia musi zostać odblokowana za pomocą PUK. - Anuluj - Szukaj karty... - Przyłóż kartę zdrowia z tyłu swojego urządzenia. - Trwa wyszukiwanie... - Powoli przesuń kartę z tyłu urządzenia. - Wskazówka - Etui urządzenia może utrudnić połączenie przez NFC. - Karta została rozpoznana - Postaraj się nie poruszać kartą. - Wykryto kartę zdrowia. Przytrzymaj ją bez przesuwania. - Połączenie zostało przerwane - Ponownie przyłóż kartę zdrowia z tyłu urządzenia - Logowanie powiodło się - Wskazówka: wczytywane są tylko recepty z ostatnich 100 dni. - Aktywowano tryb demonstracyjny - Posiadasz kartę zdrowia z obsługą technologii NFC i chcesz ją wypróbować w trybie demonstracyjnym? - Kontynuuj z kartą - Kontynuuj bez karty - Aktywowano tryb demonstracyjny - Wersja %s - Build-Hash: %s - Menu debugowania - Kod recepty - Zeskanuj ten kod recepty w swojej aptece. - Ten kod zbiorczy obejmuje %s recept(y). - Zrealizuj w aptece - Jesteś w aptece i chcesz zrealizować swoją receptę. - Zamów lub zarezerwuj - Wyślij swoją receptę do apteki i zdecyduj, w jaki sposób chcesz otrzymać swoje leki. - W tym celu potrzebujesz ważnej karty zdrowia. - Wybierz aptekę - np. Apteka Pinguin lub adres - Znajdź wygodnie aptekę - Zatwierdź lokalizację i znajdź aptekę w okolicy - Zatwierdź lokalizację - Otwarta do godz. %s - Otwarta całą dobę - Stopka redakcyjna - Wydawca - gematik GmbH\nFriedrichstraße 136\n10117 Berlin - Dyrektor zarządzający: Dr. med. Markus Leyck Dieken\nSąd rejestrowy: Amtsgericht Berlin-Charlottenburg\nNr rejestru handlowego: HRB 96351\nNIP: DE241843684 - Osoba odpowiedzialna za treść - Dr med. Markus Leyck Dieken - Kontakt - Wskazówka - Staramy się używać sformułowań neutralnych płciowo. W razie zauważenia błędów prosimy o informację drogą mailową. - Zeskanowana recepta - Lek %s - Aktualne - Aktualizuj - Archiwum - Nie zrealizowano jeszcze żadnych recept - - %s lek - %s leki - %s leków - %s leków - - Zrealizowano dnia %s - Nie zrealizowano jeszcze żadnych recept - Nowoczesna platforma medycyny elektronicznej w Niemczech - Napisz wiadomość e-mail - Otwórz stronę internetową - Witamy - Rozpocznij logowanie - Wybierz opcję Odblokuj - Odblokuj - Masz pytania lub problemy z korzystaniem z aplikacji? Nasza infolinia techniczna jest dostępna pod numerem %s. Odpowiedzi na wiele pytań udzieliliśmy już na stronie %s. - Zaloguj się - Anuluj - https://www.das-e-rezept-fuer-deutschland.de/ - das-e-rezept-fuer-deutschland.de - Ustawienia - Nieznana nazwa - Karty zdrowia - Dodaj kartę - Do wypróbowania - Tryb demonstracyjny umożliwia przetestowanie wszystkich funkcji aplikacji także bez elektronicznej karty zdrowia. - Tryb demonstracyjny - Bezpieczeństwo - Chroń informacje o swoim zdrowiu przed dostępem osób nieuprawnionych. - Nie zapisuj - Niezalecane - Biometria - Ta aplikacja używa najbezpieczniejszego czujnika biometrycznego, jaki udostępnia Twoje urządzenie. - Zabezpieczenie urządzenia - Niezalecane - Nota prawna - Stopka redakcyjna - Ochrona danych - Warunki korzystania - Aktywowano tryb demonstracyjny - Nasz tryb demonstracyjny oferuje Ci wszystkie funkcje aplikacji – bez karty zdrowia. - Chcesz się przyjrzeć aplikacji? - Nasz tryb demonstracyjny oferuje Ci wszystkie funkcje aplikacji – bez karty zdrowia. - Rozpocznij tryb demonstracyjny - Nie masz aktualnych recept - Zabezpiecz dane recepty - Lepsza ochrona Twoich danych dzięki odciskowi palców lub skanowi twarzy. - Aktywuj teraz - Szczegóły - Bądź na bieżąco - Gdy otrzymasz lek, oznacz tę receptę jako zrealizowaną. - Automatycznie aktualizuj recepty - Zarejestruj się, aby automatycznie oznaczać recepty jako zrealizowane. - Zaloguj się teraz - Dlaczego widzę tylko tę informację? - Dane o Twoim zdrowiu są objęte specjalną ochroną - Lek %1$d - Zaznacz jako zrealizowaną - Zaznacz jako niezrealizowaną - Usuń z tego urządzenia - Protokół - Zeskanowano dnia - Godz. %1$s - Możliwość zrealizowania do dnia %s - Szczegóły tego leku - Forma przekazania - Rozmiar opakowania - Centralny numer farmaceutyczny (PZN) - Stosowanie - Proszę przestrzegać wskazówek stosowania w swoim planie leczenia lub pisemnej instrukcji dawkowania podanej przez lekarza. - Osoba ubezpieczona - Nazwisko - Adres - Data urodzenia - Ubezpieczenie zdrowotne / podmiot odpowiedzialny - Status - Numer ubezpieczonego - Osoba wystawiająca receptę - Nazwisko - Lekarz specjalista - Numer lekarza (LANR) - Instytucja - Nazwa - Adres - Numer zakładu pracy - Numer telefonu - E-mail - Wypadek przy pracy - Data wypadku - Numer przedsiębiorstwa, w którym nastąpił wypadek lub numer pracodawcy - Czy chcesz nieodwołalnie usunąć tę receptę? - Usuń - Anuluj - Czy chcesz ponownie udostępnić tylko tę receptę czy wszystkie? - Wszystkie - Tylko tę - Trzeba się pospieszyć - Ten lek można wykupić w aptece także nocą bez opłaty za dyżur. - Możliwość wyboru preparatu zastępczego - Preparaty zastępcze są dozwolone. Ze względu na wytyczne ustawowe Twojej instytucji ubezpieczenia zdrowotnego możesz otrzymać alternatywę dla swojego leku. - Wiążąca rezerwacja - Zapytaj o usługę kurierską - Dostawa wysyłką - Także w przypadku leków na receptę mogą istnieć dopłaty. - Godziny otwarcia - Strona internetowa - Rezerwacja - Czy chcesz wiążąco zrealizować następujące recepty w %s? - Recepty - Zrealizuj - Usługa kurierska - Adres dostawy - Jak możemy pomóc? - Czy adres dostawy uległ zmianie? Czy chcesz przekazać aptece dodatkowe informacje? - Zadzwoń teraz - Swój adres dostawy możesz zmienić na stronie internetowej apteki wysyłkowej. - Wysyłka - Protokół - bez określonego działania - upłynął termin ważności - ważność jeszcze do dzisiaj - Zmień nazwę bloku recept - Możesz wybrać nazwę dla tego bloku recept. - Zaloguj się - Zaloguj się - Smartfon z funkcją NFC z systemem Android 7 lub wyższy - Aktywuj NFC - Aktywuj funkcję NFC w swoim urządzeniu, aby zalogować się za pomocą swojej karty zdrowia. - Aktywuj - Jak otrzymam nową kartę zdrowia? - Pomocy udzieli Ci Twoja instytucja ubezpieczenia zdrowotnego. - Jak otrzymam PIN? - PIN do Twojej karty zdrowia otrzymasz w oddzielnym liście od swojej instytucji ubezpieczenia zdrowotnego. - Czy chcesz zapisać swoje dane dostępowe do przyszłych logowań? - Zapisz dane dostępowe - Wygoda: Twoje dane będą chronione w urządzeniu dzięki zabezpieczeniom biometrycznym - Zabezpieczenie jest niemożliwe - Brak dostępnych bezpiecznych czujników lub nie skonfigurowano zabezpieczenia biometrycznego. - Nie zapisuj danych dostępowych - Oszczędność danych: wprowadzanie danych dostępowych przy każdym uruchamianiu aplikacji - Skoryguj - Przejdź do strony startowej - Zaznaczono jako zrealizowaną w dniu - Zaznaczono jako niezrealizowaną w dniu - Wyświetl jako pojedyncze kody - Wyświetl jako kod zbiorczy - %s z %s - Czy zrealizowano recepty? - Czy chcesz zaznaczyć recepty jako zrealizowane? - Niezrealizowane - Zrealizowane - Otwarcie o godz. %s - +49 800 277 377 7 - Infolinia techniczna - Otwórz skaner recept - Ustawienia - +49 800 277 377 7 - Zidentyfikuj się za pomocą odcisku palca lub skanu twarzy. - Wskazówka - Ta zmiana będzie widoczna dopiero po ponownym uruchomieniu aplikacji. - OK - Śledzenie - Pomóż nam ulepszyć tę aplikację. Wszystkie dane użytkowników są gromadzone anonimowo i służą wyłącznie do optymalizacji korzystania z aplikacji. - Zezwól na śledzenie - W razie zawieszenia systemu lub błędu aplikacji aplikacja wysyła nam informacje o przyczynach tych problemów. Wysyłane są także informacje o wersji systemu operacyjnego i używanym sprzęcie. - Blokuj tworzenie zrzutów ekranu - Zapobiega wyświetlaniu podglądu przy zmianie aplikacji - Czy zezwalasz aplikacji E-recepta na anonimową analizę Twojej aktywności? - Obejmuje to informacje o sprzęcie i oprogramowaniu w Twoim telefonie, ustawienia aplikacji E-recepta oraz zakres korzystania. Nie obejmuje to danych dotyczących Twojej osoby i Twojego zdrowia.\nDane są udostępniane przez podmiot przetwarzający wyłącznie firmie gematik GmbH i są usuwane najpóźniej po 180 dniach. Użytkownik może w każdej chwili dezaktywować analizę w menu aplikacji.\nNa podstawie tych danych możemy sprawdzić, jakie funkcje są często używane, aby je usprawnić. Możemy także ocenić, jak długo musi być obsługiwana starsza technologia oraz kiedy np. możemy określić nowszą wersję systemu operacyjnego jako wymaganą, bez komplikacji dla (zbyt wielu) użytkowników. - Zezwalaj - - Przepisano Ci %s lek - Przepisano Ci %s leki - Przepisano Ci %s leków - Przepisano Ci %s leków - - Dotknij tutaj, aby zrealizować receptę w aptece - Zrealizuj teraz - Wyświetl wszystko - Usuń receptę - Zaznaczono jako zrealizowaną - Cofnij - Wyświetl więcej - Wyświetl mniej - Informacje techniczne - Wyloguj - Zostaną usunięte wszystkie dane dostępowe do sieci medycznej. Dane Twoich recept zostaną zachowane. - Oznacza to usunięcie Twoich danych dostępowych. - Wyloguj - Anuluj - Czy chcesz się wylogować z aplikacji? - Bezpieczeństwo danych Twojej recepty - Pamiętaj, że osoby, które oprócz Ciebie korzystają z Tego urządzenia i których cechy biometryczne mogą być zapisane w tym urządzeniu lub które znają PIN do urządzenia, kod logowania lub hasło, także uzyskają dostęp do Twoich recept. - Czy zrealizować wiążąco? - Twoje recepty zostaną wysłane do tej apteki. Po wysłaniu ich zrealizowanie w innej aptece będzie niemożliwe. - Anuluj - Zrealizuj teraz - Zrealizowano pomyślnie - Apteka skontaktuje się z Tobą najszybciej, jak to możliwe, aby omówić z Tobą szczegóły dostawy. - Zakończ zamówienie w przeglądarce - Przejdź do strony startowej - Apteka wysyłkowa przygotuje koszyk z Twoimi lekami. Operacja ta może potrwać kilka minut. - Dotknij przycisku „Otwórz koszyk” i zakończ zamówienie na stronie internetowej apteki. - Przejdź do strony startowej - Wysyłanie nie powiodło się - Powtórz - Twoje zamówienie z reguły jest gotowe do odbioru w krótkim czasie. Aby poznać dokładny termin, skontaktuj się z apteką. - Twój koszyk jest gotowy - Otrzymano kod odbioru - Otrzymano powiadomienie - Wyświetl kod odbioru - Otwórz koszyk - Pokaż ten kod w swojej aptece. - Kod odbioru - Brak powiadomień - Nie masz jeszcze żadnych powiadomień - Niestety wiadomość Twojej apteki była pusta. Skontaktuj się z apteką. - Nie skonfigurowano programu poczty elektronicznej - Brak wyników - To słowo kluczowe nie dało żadnych wyników. - Licencje Open Source - Kontakt - Zadzwoń na infolinię techniczną - Napisz wiadomość e-mail - Weź udział w ankiecie - +49 800 277 377 7 - Dowiedz się więcej - Uśmiechnięta rodzina - Farmaceuta trzyma w ręce smartfon i czeka na Ciebie. - Ręka trzyma smartfon i przeprowadza uwierzytelnianie za pomocą nowej elektronicznej karty zdrowia w aplikacji - Pomóż nam ulepszyć tę aplikację - Chcemy: - Analizować przepływy użytkowników w aplikacji %s, aby optymalizować wygodę korzystania. - Wysyłać do programistów informacje o awariach i błędach %s. - Wcześnie rozpoznawać wzorce błędów, aby optymalizować infolinię techniczną. - Chcę pomóc w ulepszaniu tej aplikacji - Możesz w każdej chwili zmienić tę decyzję w ustawieniach systemowych. - anonimowo - Dalej - Obejmuje to informacje o sprzęcie i oprogramowaniu w Twoim telefonie, ustawienia aplikacji E-recepta oraz zakres korzystania. Nigdy nie gromadzimy danych dotyczących Twojej osoby ani Twojego zdrowia. - Dane są udostępniane przez podmiot przetwarzający wyłącznie firmie gematik GmbH i są usuwane najpóźniej po 180 dniach. Użytkownik może w każdej chwili dezaktywować analizę w menu aplikacji. - Na podstawie tych danych możemy sprawdzić, jakie funkcje są często używane, aby je usprawnić. Możemy także ocenić, jak długo musi być obsługiwana starsza technologia oraz kiedy np. możemy określić nowszą wersję systemu operacyjnego jako wymaganą, bez komplikacji dla (zbyt wielu) użytkowników. - Optymalizacja aplikacji - Odrzuć - Anonimowa analiza pozostaje nieaktywna - %s Dziękujemy za Twoje wsparcie! - Zamów lub zarezerwuj - Recepty zostaną automatycznie oznaczone jako zrealizowane - Recepty mogą zostać wyświetlone w obszarze „Archiwum” z opóźnieniem. - OK - Musisz być zalogowany(a), aby usunąć recepty. - Zgłoś błąd - Otrzymano błędne powiadomienie - Apteka wysłała powiadomienie w błędnym formacie. - Komunikat o błędzie z aplikacji E-recepta - Wysyłasz nam te informacje w celu analizy błędów. Informujemy, że przekazywany jest także Twój adres e-mail oraz ew. zawarta w nim nazwa. Jeżeli nie chcesz przekazywać tych informacji w całości ani w części, usuń je z tej wiadomości e-mail.\n\nWszystkie dane są zapisywane i przetwarzane przez firmę gematik GmbH lub przedsiębiorstwa działające na jej zlecenie wyłącznie w celu obsługi tego komunikatu o błędzie. Dane są usuwane automatycznie, najpóźniej po 180 dniach od obsługi zgłoszenia. Twój adres e-mail wykorzystujemy wyłącznie w celu nawiązania z Tobą kontaktu w związku z komunikatem o błędzie. W razie pytań lub przedwczesnego usunięcia danych możesz w każdej chwili skontaktować się z pełnomocnikiem ds. ochrony danych systemu E-recepta. Więcej informacji podano w aplikacji E-recepta w menu dotyczącym ochrony danych. - Zaloguj się - Zidentyfikuj się, aby pobrać recepty. - Zrealizowano dnia %s - Otrzymałeś(aś) preparat zastępczy - Proszę przestrzegać wskazówek stosowania w swoim planie leczenia lub pisemnej instrukcji dawkowania podanej przez lekarza. - Wskazówka dla aptek: Niniejsza aplikacja pozyskuje dane kontaktowe i informacje o aptekach ze strony mein-apotkekenportal.de związku Deutscher Apothekenverband e.V. Znalazłeś(aś) błąd lub chcesz skorygować dane? - Dowiedz się więcej - Apteki - Niestety nie udało się \uD83D\uDE15 - Spróbuj ponownie. - Masz pytania lub problemy z korzystaniem z aplikacji? Nasza infolinia techniczna jest dostępna pod numerem %s. - Odpowiedzi na wiele pytań udzieliliśmy już na stronie %s. - Wprowadź hasło - Dalej - Pomoc w obsłudze - Powiększ - Umożliwia powiększenie aplikacji poprzez rozsuwanie lub zsuwanie palców (pinch-to-zoom). - Wskazówka - Hasło - Zabezpiecz swoje dane indywidualnym hasłem. - Hasło - Zapisz - Wyświetl hasło - Wprowadź hasło - Możesz użyć dowolnych cyfr, liter lub znaków specjalnych. - Powtórz hasło - Siła hasła - Zalecenia: %s - Napisz wiadomość e-mail - Czekamy na Twoją opinię - Im bardziej konkretnie, tym lepiej - Podczas wysyłania wiadomości przekazywane są następujące informacje na temat używanego sprzętu i systemu operacyjnego: - System operacyjny - Android %s (wersja programisty %s) (ostatnia aktualizacja %s) - Model - %s %s (nazwa kodowa %s) - Tryb - Wersja ciemna - Wersja jasna - Język - Wyślij - Opinia zwrotna - Karta zdrowia - Rozumiem - Złóż wniosek o nową kartę zdrowia - Ta aplikacja pomoże Ci złożyć wniosek o nową elektroniczną kartę zdrowia. Nie ponosisz w związku z tym żadnych kosztów. - Zrealizowanie będzie możliwe wkrótce - Ta apteka nie może jeszcze przyjmować E-recept. - E-recepta - Gotowa na E-receptę - Otwarta teraz - Usługa kurierska - Wysyłka - Filtr - Dowolne filtry - Filtruj - Przypuszczalnie nie aktywowano w ustawieniach zatwierdzania lokalizacji. - Brak dostępnych lokalizacji - Kasa chorych - Numer ubezpieczonego - Wyślij wiadomość e-mail - Wybierz ubezpieczenie zdrowotne - Sprawdź wprowadzone dane - Numer ubezpieczonego + E-recepta + OK + Anuluj + Powrót + o + Elektronicznie. Szybko. Bezpiecznie. + Dodaj recepty + Otrzymałeś(aś) wydruk recepty? Możesz dodać receptę do aplikacji, skanując jej kod. + Rozumiem + ID zadania + Kod dostępu + Warunki korzystania + Polityka prywatności + Recepty + Odmowa dostępu do kamery + Aby móc użyć skanera, musisz w ustawieniach systemowych zezwolić aplikacji na dostęp do kamery. + Ustaw kamerę nad kodem recepty + Kod recepty jest nieprawidłowy + Ten kod recepty został już zeskanowany + + %s recepta rozpoznana + %s recepty rozpoznane + %s recept rozpoznanych + %s recept rozpoznanych + + Anuluj + Światło kamery + Czy anulować skanowanie kodów recept? + Anuluj skanowanie + Kontynuuj + Zaczynamy + Co jest potrzebne: + Wprowadź numer dostępu + Wprowadź PIN + Spróbuj ponownie + Nie udało się utworzyć połączenia z serwerem. + Wprowadzono błędny PIN. + + Masz jeszcze %s próbę, zanim Twoja karta zostanie zablokowana. + Masz jeszcze %s próby, zanim Twoja karta zostanie zablokowana. + Masz jeszcze %s prób, zanim Twoja karta zostanie zablokowana. + Masz jeszcze %s prób, zanim Twoja karta zostanie zablokowana. + + Wprowadzono błędny CAN + Numer dostępu znajduje się na górze z prawej strony Twojej karty zdrowia. + Anuluj + Szukaj karty... + Przyłóż kartę zdrowia z tyłu swojego urządzenia. + Trwa wyszukiwanie... + Powoli przesuń kartę z tyłu urządzenia. + Wskazówka + Etui urządzenia może utrudnić połączenie przez NFC. + Karta została rozpoznana + Postaraj się nie poruszać kartą. + Wykryto kartę zdrowia. Przytrzymaj ją bez przesuwania. + Połączenie zostało przerwane + Ponownie przyłóż kartę zdrowia z tyłu urządzenia + Wersja %s + Build-Hash: %s + Menu debugowania + Kod recepty + Zeskanuj ten kod recepty w swojej aptece. + Ten kod zbiorczy obejmuje %s recept(y). + Zrealizuj w aptece + Jesteś w aptece i chcesz zrealizować swoją receptę. + Zamów lub zarezerwuj + Wyślij swoją receptę do apteki i zdecyduj, w jaki sposób chcesz otrzymać swoje leki. + Zatwierdź lokalizację i znajdź aptekę w okolicy + Zatwierdź lokalizację + Otwarta do godz. %s + Otwarta całą dobę + Stopka redakcyjna + Wydawca + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Dyrektor zarządzający: Dr. med. Markus Leyck Dieken\nSąd rejestrowy: Amtsgericht Berlin-Charlottenburg\nNr rejestru handlowego: HRB 96351\nNIP: DE241843684 + Osoba odpowiedzialna za treść + Dr med. Markus Leyck Dieken + Kontakt + Wskazówka + Staramy się używać sformułowań neutralnych płciowo. W razie zauważenia błędów prosimy o informację drogą mailową. + Nowoczesna platforma medycyny elektronicznej w Niemczech + Napisz wiadomość e-mail + Otwórz stronę internetową + Witamy + Rozpocznij logowanie + Odblokuj + Zaloguj się + Anuluj + Bezpieczeństwo + Nota prawna + Stopka redakcyjna + Ochrona danych + Warunki korzystania + Szczegóły + Zaznacz jako zrealizowaną + Zaznacz jako niezrealizowaną + Forma przekazania + standardowy rozmiar + Osoba ubezpieczona + Nazwisko + Adres + Data urodzenia + Ubezpieczenie zdrowotne / podmiot odpowiedzialny + Status + Numer ubezpieczonego + Osoba wystawiająca receptę + Nazwisko + Lekarz specjalista + Numer lekarza (LANR) + Instytucja + Nazwa + Adres + Numer zakładu pracy + Numer telefonu + E-mail + Wypadek przy pracy + Data wypadku + Numer przedsiębiorstwa, w którym nastąpił wypadek lub numer pracodawcy + Czy chcesz nieodwołalnie usunąć tę receptę? + Usuń + Anuluj + Preparaty zastępcze są dozwolone. Ze względu na wytyczne ustawowe Twojej instytucji ubezpieczenia zdrowotnego możesz otrzymać alternatywę dla swojego leku. + Wiążąca rezerwacja + Zapytaj o usługę kurierską + Dostawa wysyłką + Także w przypadku leków na receptę mogą istnieć dopłaty. + Godziny otwarcia + Strona internetowa + Czy chcesz wiążąco zrealizować następujące recepty w %s? + Możliwość zrealizowania jeszcze do dzisiaj jako płatnik indywidualny + Zaloguj się + Aktywuj NFC + Aktywuj funkcję NFC w swoim urządzeniu, aby zalogować się za pomocą swojej karty zdrowia. + Aktywuj + Skoryguj + Wyświetl jako pojedyncze kody + Wyświetl jako kod zbiorczy + %s z %s + Czy zrealizowano recepty? + Czy chcesz zaznaczyć recepty jako zrealizowane? + Niezrealizowane + Zrealizowane + Otwarcie o godz. %s + +49 800 277 377 7 + Infolinia techniczna + Otwórz skaner recept + Ustawienia + Wskazówka + Ta zmiana będzie widoczna dopiero po ponownym uruchomieniu aplikacji. + OK + Blokuj tworzenie zrzutów ekranu + Zapobiega wyświetlaniu podglądu przy zmianie aplikacji + Czy zezwalasz aplikacji E-recepta na anonimową analizę Twojej aktywności? + Informacje techniczne + Bezpieczeństwo danych Twojej recepty + Pamiętaj, że osoby, które oprócz Ciebie korzystają z Tego urządzenia i których cechy biometryczne mogą być zapisane w tym urządzeniu lub które znają PIN do urządzenia, kod logowania lub hasło, także uzyskają dostęp do Twoich recept. + Wysyłanie nie powiodło się + Nie skonfigurowano programu poczty elektronicznej + Brak wyników + To słowo kluczowe nie dało żadnych wyników. + Licencje Open Source + Kontakt + Zadzwoń na infolinię techniczną + Weź udział w ankiecie + +49 800 277 377 7 + Dowiedz się więcej + Chcę pomóc w ulepszaniu tej aplikacji + Obejmuje to informacje o sprzęcie i oprogramowaniu w Twoim telefonie, ustawienia aplikacji E-recepta oraz zakres korzystania. Nigdy nie gromadzimy danych dotyczących Twojej osoby ani Twojego zdrowia. + Dane są udostępniane przez podmiot przetwarzający wyłącznie firmie gematik GmbH i są usuwane najpóźniej po 180 dniach. Użytkownik może w każdej chwili dezaktywować analizę w menu aplikacji. + Na podstawie tych danych możemy sprawdzić, jakie funkcje są często używane, aby je usprawnić. Możemy także ocenić, jak długo musi być obsługiwana starsza technologia oraz kiedy np. możemy określić nowszą wersję systemu operacyjnego jako wymaganą, bez komplikacji dla (zbyt wielu) użytkowników. + Optymalizacja aplikacji + Anonimowa analiza pozostaje nieaktywna + %s Dziękujemy za Twoje wsparcie! + Wskazówka + Recepty mogą zostać wyświetlone w archiwum z opóźnieniem. + OK + Zaloguj się + Przeprowadź identyfikację, aby pobrać recepty. + Zrealizowano dnia %s + Wskazówka dla aptek:dane kontaktowe i informacje o aptekach pozyskujemy ze strony mein-apotkekenportal.de związku Deutscher Apothekenverband e.V. Znalazłeś(-aś) błąd lub chcesz skorygować dane? + Dowiedz się więcej + Apteki + Niestety nie udało się \uD83D\uDE15 + Spróbuj ponownie. + Wprowadź hasło + Dalej + Pomoc w obsłudze + Powiększ + Umożliwia powiększenie aplikacji poprzez rozsuwanie lub zsuwanie palców (pinch-to-zoom). + Wskazówka + Hasło + Zabezpiecz swoje dane indywidualnym hasłem. + Hasło + Zapisz + Wyświetl hasło + Powtórz hasło + Zalecenia: %s + Napisz wiadomość e-mail + Podczas wysyłania wiadomości przekazywane są następujące informacje na temat używanego sprzętu i systemu operacyjnego: + Zrealizowanie będzie możliwe wkrótce + Ta apteka nie może jeszcze przyjmować E-recept. + Otwarta teraz + Usługa kurierska + Wysyłka + Filtr + Filtruj + Brak dostępnych lokalizacji + Rozumiem + Ponownie wprowadzone hasło zgadza się + + Możliwość zrealizowania jeszcze przez %s dzień jako płatnik indywidualny + Możliwość zrealizowania jeszcze przez %s dni jako płatnik indywidualny + + + + + Ważne jeszcze %s dzień + Ważne jeszcze %s dni + + + + Otwórz skaner + Przetwarzamy dane Twojego urządzenia!\nDo odczytu kodu recepty aplikacja ta wykorzystuje ML Kit firmy Google. Klikając „Akceptuję”, zgadzasz się na to, aby firma Google od czasu do czasu miała dostęp do danych Twojego urządzenia i przetwarzała je do celów analizy korzystania, diagnostyki i konfiguracji ML Kit. Możesz w dowolnym momencie cofnąć swoją zgodę, co nie wpłynie na zgodność z prawem już przeprowadzonych operacji przetwarzania. Cofnięcie zgody będzie jednak skutkowało brakiem możliwości używania skanera kodów recept. + Wyrażam zgodę + Anuluj + Błąd 20 10 76631 + Certyfikat Twojej karty zdrowia jest nieważny. Być może termin ważności Twojej karty już upłynął. Skontaktuj się ze swoją kasą chorych. + Bezskuteczne próby zalogowania się + + Stwierdzono %s bezskuteczną próbę zalogowania się. + Stwierdzono %s bezskuteczne próby zalogowania się. + Stwierdzono %s bezskutecznych prób zalogowania się. + + + Wybierz najlepsze zabezpieczenie urządzenia + Takim zabezpieczeniem może być odcisk palca, wzór odblokowania itp. + Tokeny + Token dostępu + Token SSO + Brak dostępnych tokenów dostępu + brak dostępnych tokenów SSP + skopiowano do schowka + Kliknij, aby skopiować token do schowka + Ważność jeszcze tylko do dzisiaj + Zezwól + Brak połączenia z serwerem + Spróbuj ponownie za kilka minut + Załaduj ponownie + Wyświetl tokeny + Jak chcesz zabezpieczyć aplikację? + Wskazówka + Dla tego urządzenia nie ustawiono żadnych zabezpieczeń + Zalecamy dodatkowo zabezpieczyć swoje dane medyczne za pomocą zabezpieczenia urządzenia, na przykład kodem lub zabezpieczeniem biometrycznym. + Nie pokazuj tej wskazówki w przyszłości. + Nie udało się nawiązać połączenia z siecią. + Nie udało się nawiązać połączenia z serwerem: kod statusu %s. + Nie udało się nawiązać połączenia z serwerem: błąd VAU + Ostrzeżenie + Takiemu urządzeniu nie należy w pełni ufać + Ze względów bezpieczeństwa nie powinno się używać tej aplikacji na urządzeniach zrootowanych. + Akceptuję zwiększone ryzyko i mimo to chcę kontynuować. + Dlaczego urządzenia z dostępem root stwarzają potencjalne zagrożenie dla bezpieczeństwa? + Dowiedz się więcej + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + Nazwa profilu + Podaj nazwę nowego profilu. + Nazwa profilu + Profile + Dodaj profil + Karta zdrowia + Skontaktuj się z instytucją ubezpieczenia zdrowotnego + Aby móc zalogować się w tej aplikacji, musisz posiadać kartę zdrowia obsługującą funkcję NFC i przypisany do niej kod PIN. + Otrzymasz ją bezpłatnie od swojej instytucji ubezpieczenia zdrowia. W tym celu musisz wylegitymować się za pomocą oficjalnego dokumentu tożsamości. + W ten sposób rozpoznasz kartę zdrowia obsługującą funkcję NFC + Wybierz instytucję ubezpieczenia zdrowotnego + Brak wyboru + O co chcesz wnioskować? + Kontakt za pośrednictwem tej aplikacji jest niemożliwy + Skorzystaj ze standardowych kanałów, aby skontaktować się ze swoim ubezpieczycielem. + Karta zdrowia i PIN + Tylko PIN + Skontaktuj się ze swoją instytucją ubezpieczenia zdrowotnego + Logowanie w aplikacji e-recepta + Pole nazwy nie może być puste. + Profil o podanej nazwie już istnieje. + Profil + wybrano %s + Kolor tła + Wiosenny szary + Rosiczka + To kolor różowy! + Drzewo + Niebieski księżyc; wrzesień + Nie zalogowano + Połączono + Ostatnie połączenie dnia %s + Usunąć profil? + Niniejsze dane profilu na tym urządzeniu zostaną usunięte. Twoje recepty w sieci medycznej zostaną zachowane. + Usuń + Anuluj + Usuń profil + Chcesz usunąć ostatni profil. + Aplikacja potrzebuje co najmniej jednego profilu. Podaj nazwę nowego profilu. + Błąd 20 10 76831 + Nie udało się wywołać wykazu kart zdrowia. Spróbuj ponownie. + Zweryfikowane przez ekspertów informacje dotyczące chorób, kodów ICD i tematów związanych z profilaktyką i opieką znajdziesz na Krajowym Portalu Zdrowia. + Otwórz gesund.bund.de + Zmieniliśmy politykę prywatności + Aplikacja E-recepta jest nieustannie doskonalona, co wiąże się z koniecznością aktualizacji naszej polityki prywatności. + Otwórz politykę prywatności + Zmieniło się to od %s: + Co się stanie, kiedy otworzysz aplikację? + Co się stanie, kiedy będę korzystać z funkcji kamery / odczytywać recepty za pomocą kamery? + Wybierz profil + Edytuj profile + Brak nowych recept + + Zaktualizowano %s receptę + Zaktaulizowano %s recepty + Zaktaulizowano %s recept + + + Gotowa do odbioru + W realizacji + Zrealizowana + Nieznana + Szczegóły + Wyświetl protokoły dostępu + Tutaj możesz zobaczyć, kto miał dostęp do Twoich recept + To jest kod dostępu do aplikacji E-recepta + Protokoły dostępu + Brak protokołów dostępu + Otrzymasz protokoły dostępu, jeśli jesteś zalogowany na serwerze z receptami. + Brak jeszcze protokołów dostępu. + Ostatnia aktualizacja dnia %s + Recepta jest teraz edytowana i nie można jej usunąć + Zaakceptuj + Wygląda na to, że operacja nie powiodła się + Mamy świadomść, że nawiązywanie połączenia z kartą zdrowia ma swoje ukryte wady. Dlatego w przyszłości będzie można zarejestrować się również za pomocą już uwierzytelnionej aplikacji kasy chorych.\n\nPonadto pracujemy nad tym, aby można było realizować recepty online również bez rejestracji.\n\nCzy zauważyłeś(aś) podczas tego procesu coś, czym chciał(a)byś się z nami podzielić? Czekamy na Twoje opinie, również na krytyczne informacje zwrotne. + Porady dotyczące połączenia + Zwiększ siłę połączenia + Rozwiązaniem może być usunięcie osłonki. + Jeśli urządzenie zawibruje i ostatecznie przerwie połączenie, poszukaj optymalnej pozycji, przesuwając urządzenie jedynie w małym promieniu. + Bardzo powoli przesuwaj urządzenie po karcie. + Połóż urządzenie bezpośrednio na karcie. + W tym celu połóż kartę zdrowia na równym podłożu (np. na stole). + Zwiększ siłę połączenia + Zwróć uwagę na umiejscowienie czujnika NFC + Sprawdź, gdzie znajduje się czujnik NFC w Twoim urządzeniu (tutaj np. przegląd urządzeń %s). + Umiejscowienie czujnika NFC może różnić się w zależności od wariantu urządzenia (tutaj podane są dane dla %s). + Następna porada + Dalej + Zamknij + Wypróbuj + Napisz do nas + Licencja na wyszukiwarkę aptek + Zrealizuj + Zeskanowana recepta + Zeskanowano dnia %s + Zaznaczono jako zrealizowaną w dniu %s + Jak chcesz kontynuować? + Zamów + Dostępne wkrótce + Zarezerwuj teraz w celu odbioru, wysyłki lub dostawy kurierem + Zapisz na potrzeby kolejnych zamówień + Zapisz recepty na urządzeniu + + Kontynuuj z %s receptą + Kontynuuj z %s receptami + + + + Połączenie karty zdrowia nie powiodło się + Aktualny profil jest już powiązany z inną kartą zdrowia (numer ubezpieczenia zdrowotnego %s). + Twoja karta zdrowia jest już powiązana z innym profilem. Zmień na profil %s. + Moje zamówienie + Zarezerwuj teraz + Zamów teraz + Zapisz + Dane kontaktowe i adres + Kontakt + Numer telefonu + Proszę podać numer telefonu w celach kontaktowych. + Adres e-mail (opcjonalnie) + Adres dostawy + Imię i nazwisko + Proszę podać imię i nazwisko w celach kontaktowych. + Ulica i numer domu + Proszę podać ulicę i numer domu w celach kontaktowych. + Dodatkowe dane adresowe (opcjonalnie) + Kod pocztowy i miejscowość + Proszę podać kod pocztowy i miejscowość w celach kontaktowych. + Wskazówki dotyczące dostawy (opcjonalnie) + Twoja recepta zostanie wysłana do tej apteki. Po wysłaniu jej zrealizowanie w innej aptece będzie niemożliwe. + Dane kontaktowe i adres dostawy + Recepty + Potrzebujemy Twoich danych kontaktowych, aby apteka mogła udzielić Ci porady oraz aby informować Cię o aktualnym statusie Twojego zamówienia. + Podaj dane kontaktowe + Potrzebne są dodatkowe dane kontaktowe + Zamówienie zostało przekazane + Twoja apteka skontaktuje się z Tobą jak najszybciej. + Zamknij + Odrzucić zmiany? + Odrzuć + Na potrzeby wyszukiwania wykaz aptek wykorzystuje współrzędne geograficzne, które zostały ustalone za pomocą OpenStreepMap. Dziękujemy temu projektowi za pomoc. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + Warunki korzystania i ochrona danych + Dalej + Kod PIN otrzymałeś(aś) w liście od swojej instytucji ubezpieczenia zdrowotnego. + Nie otrzymałem(am) kodu PIN + PIN + Sprawdź połączenie z internetem oraz ustawienie godziny/daty na swoim urządzeniu. + Aby przeprowadzić uwierzytelnienie, naciśnij \"Odblokuj\". + Nie możesz uzyskać dostępu? Sprawdź swoje biometryczne dane dostępowe na tym urządzeniu. + Nie pamiętasz hasła? Usuń aplikację i następnie zainstaluj ją ponownie. Z naszego %s dowiesz się, dlaczego tak się dzieje. + Pomoc + wielkość opakowania i jednostka + Substancja czynna + Ilość substancji czynnej + Oznaczenie serii + Ważność do dnia + Kategoria + Szczepionka + Zaakceptuj + Cofnij + Wskazówka + Pomożesz nam ulepszyć tę aplikację? + Wybierz własne hasło + Hasło musi zawierać co najmniej osiem znaków + Niewystarczająca siła hasła + Wystarczająca siła hasła + Hasło jest widoczne + Hasło jest niewidoczne + Biometria + Hasło + Poczekaj na odpowiedź + Brak recept + Obecnie nie masz recept do zrealizowania. + Aktualizuj + Automatyczne wylogowanie + Ze względów bezpieczeństwa połączenie z serwerem z receptami zostanie przerwane po 12 godzinach. Nawiąż połączenie ponownie, aby wywołać aktualne recepty. + Nawiąż połączenie + Czy otrzymałeś(aś) wydruk papierowy? + Aby dodać do swojej listy recepty, kliknij przycisk \"Skanuj\" w prawym górnym rogu. + Zeskanuj wydruk papierowy + Aby automatycznie otrzymywać recepty, musisz się zalogować. + Zaloguj się + Brak zrealizowanych recept + Tutaj zostaną wyświetlone Twoje zrealizowane recepty. Ze względu na ochronę danych Twoje recepty zostaną usunięte z serwera z receptami po 100 dniach. + Brak zrealizowanych recept + Tutaj zostaną wyświetlone Twoje zrealizowane recepty. Dodaj recepty za pomocą funkcji skanowania, aby rozpocząć realizację recept. + Zarządzanie urządzeniami + Podłączone urządzenia + Zarejestrowane od %s (to urządzenie) + Zarejestrowane od %s + Aktualne + Archiwum + Zrealizować ponownie? + Wskazówka: apteka, która jako pierwsza zaakceptowała receptę, blokuje ją, aby inna apteka nie mogła jej edytować. + Anuluj + OK + + Już wysłałeś(aś) receptę %s do apteki. Czy mimo to chcesz ją ponownie zrealizować? + Już wysłałeś(aś) jedną z tych recept do apteki. Czy mimo to chcesz wysłać ją do innych aptek? + + + + Ze względów bezpieczeństwa połączenie z serwerem receptur zostaje zakończone po 12 godzinach. Aby ponownie nawiązać połączenie, potrzebujesz karty zdrowia i kodu PIN dla każdego procesu łączenia. + PIN + Wprowadź PIN (karty zdrowia) + Dalej + Uwierzytelnienie + Podłączone urządzenia + Usunąć urządzenie? + Anuluj + Usuń + Czy usunąć to urządzenie? + Czy chcesz usunąć %s? + Jeśli usuniesz %s, połączenie z serwerem z receptami zostanie przerwane na stałe maksymalnie w ciągu 12 godzin. + Trwa ładowanie urządzeń... + Brak urządzeń + Brak urządzeń podłączonych do tej karty zdrowia. + Spróbuj ponownie + Och nie :-( + Nie udało się załadować listy urządzeń. + Brak połączenia... + Brak połączenia z internetem. + Leki i środki opatrunkowe + Środki znieczulające + Leki na receptę wydawane są zgodnie z § 4 niemieckiego rozporządzenia w sprawie obowiązku wypisywania recept na leki (AMVV) + Potrzebujesz pomocy? + Zebraliśmy kilka wskazówek, jak rozwiązać najczęściej występujące problemy. + Wyświetl porady dotyczące połączenia + Zeskanowano dnia: %s + Zeskanowana recepta + Odblokuj + Karta została zablokowana + Kod PIN został wprowadzony niepoprawnie trzy razy. Dlatego Twoja karta została zablokowana ze względów bezpieczeństwa. + Odblokuj kartę + Wprowadź PUK + Razem z kodem PIN otrzymałeś(aś) od swojej instytucji ubezpieczenia zdrowotnego 8-cyfrowy kod PUK. + Wybierz nowy kod PIN + Możesz sam(a) wybrać swój nowy osobisty numer identyfikacyjny (PIN) (od 6 do 8 znaków). + Pamiętasz kod PIN? + Zanotuj swój kod PIN i przechowuj go w bezpiecznym miejscu. + Anuluj + Wprowadzono błędny PUK. + OK + Odblokowanie jest niemożliwe + Za pomocą tego kodu PUK została wykorzystana maksymalna liczba odblokowań karty lub kod był wielokrotnie błędnie wprowadzany. Skontaktuj się ze swoim ubezpieczycielem. + Za pomocą kodu PUK możesz wykonać do 10 odblokowań. + Karta odblokowana + Co jest potrzebne: + Twoja karta zdrowia + PUK do Twojej karty zdrowia + Dalej + Karta zdrowia + Zamów nową kartę + Zaloguj się + Odbieraj recepty online i przesyłaj je do apteki. + Karta zdrowia obsługująca funkcję NFC + PIN do karty zdrowia + Nie masz jeszcze karty zdrowia obsługującej funkcję NFC i kodu PIN? + Zamów teraz + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + Albo: zaloguj się za pomocą %s. + Aplikacja Twojej instytucji ubezpieczenia zdrowotnego + "Numer dostępu (Card Access Number, w skrócie: CAN) znajdziesz w prawym górnym rogu swojej karty zdrowia." + Moja karta nie ma numeru dostępu + + Masz jeszcze %s próbę, zanim Twoja karta zostanie zablokowana. + Masz jeszcze %s prób, zanim Twoja karta zostanie zablokowana. + + + + Przyłóż kartę zdrowia z tyłu telefonu + Proces ten może potrwać do 30 sekund. + Umieść kartę %s z tyłu telefonu. + na górze z prawej strony + na górze pośrodku + na górze z lewej strony + na środku z prawej strony + na środku + na środku z lewej strony + na dole z prawej strony + na dole pośrodku + na dole z lewej strony + Pomoc + Wysłano %s minut temu + Wysłano dnia %s + Właśnie wysłano + Wysłano o godzinie %s + Ważność upłynęła + Zaloguj się za pomocą aplikacji + Wybierz ubezpieczenie + Nie znalazłeś tego, czego szukałeś? Lista ta jest stale uzupełniana. Logowanie za pomocą karty zdrowia jest już obsługiwane przez każdą kasę chorych. + Informacje zwrotne z aplikacji E-recepta + Czekamy na Twój feedback. Postaraj się sformułować swoje opinie możliwie precyzyjnie i zapisz je poniżej: + PUK + Zamknij + Szkoda... + Niestety Twoje urządzenie nie spełnia warunków minimalnych umożliwiających zalogowanie się w aplikacji E-recepta. Do bezpiecznego uwierzytelnienia za pomocą karty zdrowia potrzebne są co najmniej wersja Android 7 i czip NFC. + Dowiedz się więcej + Zapisać dane dostępowe? + Zapisz + Nie zapisuj + Wskazówka + Ze względów bezpieczeństwa połączenie z serwerem receptur zostaje zakończone po 12 godzinach. Aby ponownie nawiązać połączenie, potrzebujesz karty zdrowia i kodu PIN dla każdego procesu łączenia. + Ustaw zabezpieczenie biometryczne + Nie można zapisać danych dostępowych. Wcześniej utwórz zabezpieczenie biometryczne (np. odcisk palca) na swoim urządzeniu. + Anuluj + Ustawienia + Wskazówka + Zaakceptuj + Bezpieczeństwo danych Twojej recepty + \"Ta aplikacja używa najbezpieczniejszego czujnika biometrycznego, jaki udostępnia Twoje urządzenie, aby zabezpieczyć Twoje dane dostępowe w chronionym obszarze pamięci urządzenia.\" + Biometryczne zabezpieczenie Twoich danych dostępowych umożliwia otwieranie tej aplikacji w przyszłości bez karty zdrowia i podawania kodu PIN, a także przeglądanie, wywoływanie, realizowanie lub kasowanie recept. + Pamiętaj, że osoby, które oprócz Ciebie korzystają z Tego urządzenia i których cechy biometryczne mogą być zapisane w tym urządzeniu lub które znają PIN do urządzenia, kod logowania lub hasło, także uzyskają dostęp do Twoich recept. + Niestety nie udało się + Uwierzytelnienie za pomocą aplikacji kasy nie powiodło się. + Wygasł %s + Przepis został już usunięty z serwera + Popraw wprowadzone dane lub odrzuć zmiany + Skoryguj + Dane ubezpieczonego + Imię i nazwisko + Ubezpieczenie + Numer ubezpieczonego + Numer dostępu (CAN) + Zaloguj się + Wyloguj + Aby automatycznie otrzymywać recepty, musisz się zalogować. + Zapisz + Zmiana + Edytuj zdjęcie profilowe + Dalej + Serwer nie odpowiada + Spróbuj ponownie później. + Spróbuj ponownie + Poszukaj ubezpieczenia + Połączyć się teraz z serwerem receptur? + zalogowano pomyślnie + utracono połączenie + Połączyć się teraz z serwerem receptur? + Brak tokenów + Token otrzymasz po zalogowaniu się do obsługi recept.\n + Zamówienia + Wybierz żądany kod PIN + Odblokuj kartę + Wybierz PIN + Powtórz PIN + Wprowadzone hasła różnia się od siebie. + Brak zamówień + Nie masz jeszcze żadnych zamówień. + Właśnie + O godzinie %s + Koszyk jest gotowy + Przepis został dodany do Twojego koszyka. Wejdź na stronę apteki, aby sfinalizować zamówienie. + Otwórz koszyk + Pokaż ten kod odbioru w aptece. + Otrzymano kod odbioru + Wiadomość nie może zostać wyświetlona + Skontaktuj się ze swoją apteką ( %s ). + Pokaż link do koszyka + Wyświetl kod odbioru + Pokaż wiadomość + %s o godzinie %s + Przepis wysłano do %s . + Przegląd zamówień + Nowy + Kurs + porządek + Bezpłatnie dla dzwoniącego. Godziny obsługi: pon. - pt. 8:00 - 20:00 z wyjątkiem świąt państwowych + Apteka + Wybierz żądany kod PIN + Zapisano żądany kod PIN + Nie można zapisać żądanego kodu PIN + E-recepta + Obecnie otwarte i blisko mnie + Filtruj według … + Zacznij szukać + Anuluj + Zostaną wykupione dla ciebie + bezpośrednie przypisanie + Zrealizuj + numer telefonu (opcjonalnie) + Szukaj według nazwy lub adresu + Brak prawidłowych informacji o aptece + Nie znaleziono aktualnych informacji o tej aptece. Wpis dotyczący tej apteki zostanie usunięty. + OK + Katalog aptek jest niedostępny + Obecnie nie można uzyskać aktualnych informacji o tej aptece. Proszę sprawdzić swoje połączenie z internetem. + Anuluj + Spróbuj ponownie + Zapisz środowisko + Nie można się zalogować + Wygląda na to, że Twoje dane logowania biometrycznego uległy zmianie. Zarejestruj się ponownie za pomocą swojej karty zdrowia. + Anuluj + Zaloguj się + profil 1 + Blisko mnie + Nie mają recept zwrotnych + Do wykorzystania później + Do wykorzystania od %s + %s / %s + ulepszenia produktu + Anonimowa analiza + Pomóż nam ulepszyć tę aplikację. Wszystkie dane użytkownika są zbierane anonimowo i służą wyłącznie do poprawy komfortu użytkowania. + bezpieczeństwo urządzenia + ustawienia osobiste + Pomoc w obsłudze + ulepszenia produktu + Dodano przepis + Przepis już dostępny + Wystąpił błąd podczas importowania + Usuń + Zeskanowana recepta + Możliwość wyboru preparatu zastępczego + Wysłane w dniu %s o %s + Wykorzystano w %s w %s + Nie pamiętam kodu PIN + + %s Przepis + + + %s Przepisy + + Zapoznałem się i akceptuję politykę prywatności i warunki użytkowania. + Polityka prywatności + Warunki korzystania + Chcielibyśmy: + Popraw użyteczność. + Wykrywaj błędy i awarie. + Wszystkie dane są oczywiście zbierane anonimowo. + Możesz w każdej chwili zmienić tę decyzję w ustawieniach systemowych. + Kontynuuj + Zaakceptuj + Ta aplikacja korzysta z najbezpieczniejszej metody dostępnej na Twoim urządzeniu. + Zapisz + Wybierać + Lek + Nazwa handlowa + TAk + nie + dawkowanie + Data wydania + noctu + Ta recepta zostanie zrealizowana w ramach leczenia. + Brak danych + Brak opłaty za usługi ratunkowe + dodatkowa opłata + Lek + Dokumenty dostawy + Kwalifikuje się zgodnie z BVG + przygotowanie alternatywne + nazwa przepisu + Opakowania + instrukcja rzemieślnicza + opis + podane przez + wydany w: + Substancja czynna + przepisany + Odbierać + Czym jest przydział bezpośredni? + W przypadku skierowań bezpośrednich, receptę z przychodni lub szpitala realizuje się bezpośrednio w aptece. Ubezpieczeni nie muszą podejmować żadnych działań i nie mogą ingerować w proces wykupu. \n\n Bezpośrednie skierowania są wymienione w aplikacji e-recept, aby Twoje leczenie było dla Ciebie bardziej przejrzyste. + Brak opłaty za usługi ratunkowe + Pośpiech jest tutaj na porządku dziennym. Receptę tę można również zrealizować w nocy w aptece bez dodatkowej opłaty za pomoc w nagłych wypadkach. + Leki objęte współpłatnością + Zwolnione ze współpłacenia + Osoby posiadające ustawowe ubezpieczenie zdrowotne muszą zapłacić do dziesięciu euro za leki na receptę. \n\n Wysokość dopłaty zależy od ceny leku. Sam musisz zapłacić za leki, które kosztują mniej niż 5 euro.\n W przypadku droższych leków trzeba zapłacić dziesięć procent ceny, ale co najmniej 5 euro, a maksymalnie 10 euro. \n\n Dzieci i młodzież poniżej 18 roku życia są z reguły zwolnione ze współpłacenia. \n\n Jeśli Twoje roczne koszty leków przekraczają Twój limit finansowy, możesz zostać zwolniony ze współpłacenia. Porozmawiaj o tym ze swoim ubezpieczycielem zdrowotnym. + Jesteś zwolniony ze współpłacenia tego leku. Twoje ubezpieczenie zdrowotne pokryje koszty leków. + Jak długo ta recepta jest ważna? + W tym okresie możesz zrealizować receptę w dowolnej aptece za dodatkową opłatą. + W tym okresie możesz jeszcze zrealizować receptę w aptece, ale musisz sam zapłacić cenę zakupu. Możesz też poprosić swoją praktykę o ponowne wystawienie recepty. + Możliwość wyboru preparatu zastępczego + Ze względu na wymagania prawne Twojej firmy ubezpieczeniowej, możesz otrzymać alternatywę z tą samą substancją czynną. \n\n Leki mogą wyglądać i nazywać się inaczej, mieć różne ceny i producentów, a mimo to zawierają ten sam składnik aktywny. Sam składnik aktywny i dawkowanie są szczególnie ważne dla działania leków na organizm. Pacjenci w aptece często otrzymują inny lek niż ten przepisany przez lekarza na receptę – pod warunkiem, że leki są porównywalne. Zmiana może mieć przyczyny terapeutyczne i ekonomiczne. + Zeskanowana recepta + Ze względów bezpieczeństwa recepty importowane z wydruku papierowego nie mogą zawierać żadnych danych osobowych ani medycznych. \n\n Zaloguj się do tej aplikacji za pomocą karty zdrowia lub aplikacji ubezpieczeniowej, aby wyświetlić wszystkie informacje zawarte na recepcie. + Nieprawidłowy przepis + Ta recepta została wystawiona nieprawidłowo. + Zeskanowana recepta + opłata za usługi ratunkowe + Dawkowanie zgodnie z pisemną instrukcją + Telefon + strona + E-mail + Sortowanie według odległości nie jest możliwe. + OK + Wpisz aktualny kod PIN + Wprowadzono nieprawidłowy kod PIN + Aktualny PIN Twojej karty zdrowia + Karta została zablokowana + Odblokuj kartę w Ustawienia > Odblokuj kartę. + Ze względów bezpieczeństwa wprowadź aktualny kod PIN. + Nie pamiętam kodu PIN + Błędna recepta + Lek + Wygląda na to, że podczas wystawiania Twojej recepty coś poszło nie tak. Czy zgłosić błąd? + Zgłoś + Nie zalogowano + Zarejestrowany z + Karta zdrowia + Biometria + Nie zalogowano + Interesuje nas Twoja opinia. Poświęć pięć minut na wypełnienie naszej ankiety. Z góry dziękuję. + ostrzeżenie + Apteka dodana do ulubionych + Usunięto aptekę z ulubionych + Moje apteki + Bardzo dobra siła hasła + Operacja zapisu nie powiodła się + Nie udało się zapisać kodu PIN + Zgłoś + Przypisz PIN + Naruszono regułę dostępu + Nie masz uprawnień dostępu do katalogu map. + Przypisz swój własny kod PIN + Karta jest zabezpieczona kodem PIN z kasy chorych (transport PIN), prosimy o nadanie własnego kodu PIN. + Nie znaleziono hasła + Na Twojej karcie nie jest zapisane żadne hasło. + Zostałeś wylogowany + Zaloguj się ponownie, aby zaktualizować swoje przepisy. + numer składnika aktywnego + moc i jedność diff --git a/android/src/main/res/values-ru/strings.xml b/android/src/main/res/values-ru/strings.xml index 8069bcc4..1e7560df 100644 --- a/android/src/main/res/values-ru/strings.xml +++ b/android/src/main/res/values-ru/strings.xml @@ -1,487 +1,789 @@ - E-Rezept - OK - Отмена - Назад - в - %1$s - Дата последнего обновления %1$s - Не удалось выполнить обновление. Попробуйте обновить рецепты позже. - Цифровизация. Оперативность. Надежность. - Добро пожаловать в приложение E-Rezept - С его помощью вы можете выкупить медикаменты по электронному рецепту в выбранной вами аптеке – лично или онлайн. - Больше функций с вашей медицинской карточкой - Автоматическое обновление рецептов - Информация о приеме и дозировке лекарств - Сообщения из аптеки о вашем заказе - Условия использования и политика конфиденциальности - Чтобы использовать приложение, примите условия использования и подтвердите, что вы прочитали политику конфиденциальности. Собираются только данные, необходимые для функционирования сервисов. - Я прочитал(а) и принимаю %s. - условия использования - политику конфиденциальности - Подтвердить - Далее - Подтвердить - Добавить рецепты - Вы получили распечатанный рецепт? Для добавления рецептов в приложение отсканируйте код рецепта. - Понятно - ID задачи - Код доступа - Скопировано - Условия использования - Политика конфиденциальности - Принять условия использования - Принять политику конфиденциальности - Рецепты - Рецепты - Сообщения - Выкупить - - Осталось дней: %s - - - Осталось дней: %s - - например, дерматолог - Опознано %s %s из %s %s. Сканировать другие коды? - - рецепт - - - рецепты - - - рецепт - - - рецепты - - - добавить %s рецепт - - - Добавить %s рецепта/-ов - - Доступ к камере запрещен - Для использования сканера необходимо разрешить приложению доступ к камере в системных настройках. - Сфокусируйте камеру на коде рецепта - Код рецепта недействителен - Этот код рецепта уже был отсканирован - - Распознан %s рецепт - - - Распознано %s рецепта/-ов - - Отмена - Подсветка камеры - Прервать сканирование кодов рецептов? - Прервать сканирование - Продолжить - Добавить карточку - Давайте начнем - Воспользуйтесь всеми функциями сейчас - Чтобы использовать все функции приложения, войдите в систему, используя свою медицинскую карточку. Вы получите эту карточку и необходимые для доступа данные от своей организации медицинского страхования. - Вам потребуется: - медицинская карточка с номером доступа (CAN) - PIN-код медицинской карточки - Добавить карточку - Как жаль… - К сожалению, ваше устройство не соответствует минимальным требованиям для входа в приложение E-Rezept. - Почему установлены минимальные требования для входа в систему с помощью медицинской карточки? - Номер доступа к карте (CAN) состоит из 6 цифр. CAN вы найдете в правом верхнем углу на лицевой стороне вашей медицинской карточки. Если здесь нет шестизначного номера доступа, вам понадобится новая медицинская карточка от вашей организации медицинского страхования. - Введите номер доступа - Вы можете ввести любые цифры. - PIN-код может состоять из 6-8 цифр. - Ввести PIN-код - В демонстрационном режиме вы можете ввести произвольный PIN-код. - Попробовать снова - Подготовьте свою электронную медицинскую карточку. - Время, необходимое для подключения вашего устройства к серверу, может варьироваться в зависимости от аппаратных характеристик устройства и скорости интернет-соединения. - Не удалось подключиться к серверу. - Проверьте соединение с интернетом и попытайтесь еще раз. - Введен неправильный PIN-код. - - У вас осталась еще %s попытка, прежде чем ваша карточка будет заблокирована. - - - У вас осталось еще %s попытки / попыток, прежде чем ваша карточка будет заблокирована. - - Введен неправильный CAN. - Номер доступа указан в верхнем правом углу медицинской карточки. - PIN-код был неправильно введен несколько раз. - Для разблокировки вашей медицинской карточки требуется PUK-код. - Отмена - Поиск карточки... - Приложите медицинскую карточку к обратной стороне устройства. - Поиск продолжается... - Медленно переместите карточку с обратной стороны устройства. - Совет - Чехол может помешать соединению через NFC. - Карточка распознана - Попытайтесь не двигать медицинскую карточку. - Медицинская карточка найдена. Не двигайте ее. - Соединение прервано - Снова приложите медицинскую карточку к обратной стороне устройства - Вы успешно вошли в систему - Внимание: загружаются только рецепты за последние 100 дней. - Демонстрационный режим активирован - У вас есть медицинская карточка с поддержкой NFC, и вы хотите протестировать ее в демонстрационном режиме? - Продолжить работу с карточкой - Продолжить работу без карточки - Демонстрационный режим активирован - Версия: %s - Хэш сборки: %s - Меню отладки - Код рецепта - Отсканируйте этот код рецепта в аптеке. - Этот сводный код объединяет %s рецепта/-ов - Выкупить в аптеке - Вы находитесь в аптеке и хотите получить препарат по рецепту. - Заказать или зарезервировать - Отправьте свой рецепт в аптеку и решите, как вы хотите получить препарат. - Для этого вам потребуется действительная медицинская карточка. - Выбрать аптеку - например, аптека \"Pinguin\" или адрес - Удобный поиск аптек - Разрешите определение местоположения и найдите аптеки поблизости от вас - Разрешить определение местоположения - Открыто до %s - Открыто круглосуточно - Выходные данные - Издатель - gematik GmbH\nFriedrichstraße 136\n10117 Berlin - Руководитель: д-р мед. наук Маркус Лейк Дикен\nРегистрационный суд: участковый суд Берлин-Шарлоттенбург\nНомер в торговом реестре: HRB 96351\nИдентификационный номер плательщика НДС: DE241843684 - Ответственный за содержание - д-р мед. наук Маркус Лейк Дикен - Контактная информация - Указание - Мы стремимся использовать язык гендерного равенства. Если вы заметите какие-либо ошибки, мы будем рады получить от вас письмо по электронной почте. - Отсканированный рецепт - Препарат %s - Актуальные - Обновить - Архив - Вы еще не выкупили ни одного рецепта - - %s препарат - - - %s препарата/-ов - - Выкуплен %s - Вы еще не выкупили ни одного рецепта - Современная немецкая платформа цифровой медицины - Написать электронное письмо - Открыть сайт - Добро пожаловать - Начать процедуру входа - Нажмите \"Разблокировать\" - Разблокировать - У вас есть вопросы или проблемы с использованием приложения? Вы можете позвонить на нашу горячую линию по техническим вопросам по телефону %s. Мы уже ответили на многие вопросы на сайте %s. - Войти - Отмена - https://www.das-e-rezept-fuer-deutschland.de/ - das-e-rezept-fuer-deutschland.de - Настройки - Фамилия неизвестна - Медицинские карточки - Добавить карточку - Протестировать - В демонстрационном режиме вы можете познакомиться со всеми разделами приложения без медицинской карточки. - Демонстрационный режим - Безопасность - Защитите информацию о своем здоровье от посторонних. - Не защищать - Не рекомендуется - Биометрия - Это приложение использует самый безопасный биометрический датчик вашего устройства. - Защита устройства - Не рекомендуется - Юридическая информация - Выходные данные - Защита данных - Условия использования - Демонстрационный режим активирован - В демонстрационном режиме вы познакомитесь со всеми функциями приложения – без медицинской карточки. - Хотите ознакомительную экскурсию? - В демонстрационном режиме вы познакомитесь со всеми функциями приложения – без медицинской карточки. - Запустить демонстрационный режим - У вас нет активных рецептов - Сохранить данные рецепта - Повышенная защита ваших данных с помощью отпечатка пальца или сканирования лица. - Активировать сейчас - Подробности - Оставайтесь в курсе - Отметьте этот рецепт как выкупленный, как только получите препарат. - Автоматически обновлять рецепты - Войдите в систему, чтобы ваши рецепты автоматически помечались как выкупленные. - Войти в систему - Почему я вижу только эту информацию? - Информация о вашем здоровье находится под особой защитой - Препарат %1$d - Отметить как выкупленный - Отметить как не выкупленный - Удалить с этого устройства - Протокол - Отсканирован - %1$s - Можно выкупить до %s - Подробности об этом препарате - Форма выпуска - Размер упаковки - Центральный фармацевтический номер (PZN) - Указания по применению - Следуйте указаниям по применению в вашем плане приема лекарств или письменным инструкциям вашего врача по дозировке препарата. - Застрахованное лицо - Фамилия - Адрес - Дата рождения - Организация медицинского страхования / плательщик - Статус - Страховой номер - Лицо, выписавшее рецепт - Фамилия - Врач - Номер врача (LANR) - Учреждение - Фамилия - Адрес - Номер учреждения - Номер телефона - Электронная почта - Производственная травма - Дата происшествия - Номер предприятия, на котором произошел несчастный случай, или номер работодателя - Вы хотите навсегда удалить этот рецепт? - Удалить - Отмена - Восстановить доступ только к этому рецепту или ко всем? - Все - Только этот - Поторопитесь - Этот препарат можно выкупить в аптеке и ночью без платы за обслуживание в нерабочее время. - Возможна замена препарата - Препараты-заменители допустимы. В соответствии с юридическими требованиями вашей организации медицинского страхования вам может быть выдан альтернативный препарат. - Зарезервировать - Запросить доставку курьером - Заказать отправку по почте - Примите во внимание, что за прописанные препараты может взиматься дополнительная плата. - Часы работы - Веб-сайт - Резервирование - Выкупить следующие рецепты в %s? - Рецепты - Выкупить - Курьерская доставка - Адрес доставки - Чем мы можем вам помочь? - Изменился адрес доставки? Вы хотите что-то сообщить аптеке? - Позвонить сейчас - Вы можете изменить адрес доставки на сайте аптеки, отправляющей препараты. - Отправка - Протокол - действие не задано - истек - действителен только сегодня - Переименовать группу рецептов - Вы можете присвоить имя этой группе рецептов. - Войти - Войти - Смартфон с поддержкой NFC под управлением Android 7 или выше - Активировать NFC - Активируйте функцию NFC на своем устройстве, чтобы войти в систему со своей медицинской карточкой. - Активировать - Как получить новую медицинскую карточку? - В этом вам поможет ваша организация медицинского страхования. - Как получить PIN-код? - PIN-код для медицинской карточки вы получите отдельным письмом от вашей организации медицинского страхования. - Сохранить данные доступа для входа в систему в дальнейшем? - Сохранить данные доступа - Удобно: биометрическая защита ваших данных на устройстве - Установить защиту невозможно - Надежные датчики недоступны, или не настроена биометрическая защита. - Не сохранять данные доступа - Минимум данных: данные доступа потребуется вводить при каждом запуске приложения - Исправить - На главную страницу - Отмечен как выкупленный - Отмечен как не выкупленный - Показать отдельные коды - Показать сводный код - %s из %s - Рецепты выкуплены? - Отметить рецепты как выкупленные? - Не выкуплены - Выкуплены - Откроется в %s - +49 800 277 377 7 - Техническая горячая линия - Открыть сканер рецептов - Настройки - +49 800 277 377 7 - Пройдите идентификацию с помощью отпечатка пальца или сканирования лица. - Указание - Это изменение вступит в силу только при следующем запуске приложения. - OK - Отслеживание - Помогите нам сделать это приложение лучше. Все данные об использовании собираются анонимно и служат исключительно для улучшения пользовательского опыта. - Разрешить отслеживание - В случае сбоя или ошибки в приложении приложение отправляет нам информацию о причинах. Кроме того, отправляются данные о версии операционной системы и об используемом оборудовании. - Скрывать скриншоты - Скрывать предпросмотр при смене приложения - Разрешаете ли вы приложению E-Rezept анонимно анализировать поведение пользователя? - Этот анализ включает в себя информацию об аппаратном и программном обеспечении вашего телефона, настройках приложения E-Rezept и объеме его использования, но никогда не включает данные о вас или вашем здоровье. Обработчики данных предоставляют эту информацию исключительно компании gematik GmbH и удаляют ее не позднее чем через 180 дней. Вы можете в любое время отключить анализ в меню приложения.\nЭти данные позволяют нам понять, какие функции часто используются, и оптимизировать их. Кроме того, мы оцениваем, на протяжении какого времени требуется поддержка старого оборудования и когда, например, мы можем включить в системные требования более новую версию операционной системы, чтобы изменения затронули как можно меньше пользователей. - Разрешить - - Вам прописан %s медикамент - - - Вам прописано %s медикамента/-ов - - Нажмите здесь, чтобы выкупить их в аптеке - Выкупить сейчас - Показать все - Удалить назначение - Отмечен как выкупленный - Отменить - Показать больше - Показать меньше - Техническая информация - Выйти - Вы удалили все данные доступа к сети здравоохранения. Данные ваших рецептов сохраняются. - Ваши данные доступа будут удалены. - Выйти - Отмена - Вы хотите выйти из приложения? - Защита данных ваших рецептов - Примите во внимание: если этим устройством вместе с вами пользуются другие люди, которые сохранили свои биометрические параметры на устройстве или знают его PIN-код, графический ключ или пароль, они могут получить доступ к вашим рецептам. - Действительно выкупить? - Ваши рецепты будут отправлены в эту аптеку. После этого вы не сможете выкупить их ни в одной другой аптеке. - Отмена - Выкупить сейчас - Успешно выкуплен - Сотрудники аптеки свяжутся с вами в кратчайшие сроки для уточнения деталей доставки. - Завершите заказ в браузере - Перейдите на главную страницу - Аптека, отправляющая заказ, формирует корзину с вашими препаратами. Это может занять несколько минут. - Нажмите «Открыть корзину» и завершите заказ на сайте аптеки. - На главную страницу - Не удалось отправить - Повторить - Обычно заказы собираются оперативно. Чтобы узнать точное время, обратитесь в аптеку. - Ваша корзина готова - Получить код самовывоза - Сообщение получено - Показать код получения - Открыть корзину - Покажите этот код в аптеке. - Код получения - Нет сообщений - Вы еще не получили ни одного сообщения - К сожалению, сообщение из вашей аптеки было пустым. Обратитесь в свою аптеку. - Приложение электронной почты не настроено - Результаты не найдены - По данному поисковому запросу результаты не найдены. - Лицензии на ПО с открытым исходным кодом - Контактная информация - Позвонить на горячую линию - Написать электронное письмо - Принять участие в опросе - +49 800 277 377 7 - Узнать больше - Улыбающаяся семья - Фармацевт со смартфоном в руке радуется вам. - Держит смартфон в руке и проходит аутентификацию в приложении с помощью новой электронной медицинской карточки - Помогите нам сделать это приложение лучше - Мы планируем: - Анализировать пользовательские потоки в приложении %s, чтобы сделать его более удобным. - Отправлять разработчикам информацию о сбоях и сообщения об ошибках %s. - Оперативно выявлять типы ошибок, чтобы оптимизировать работу горячей линии. - Я хочу помочь работе по улучшению этого приложения - Вы можете в любое время изменить это решение в системных настройках. - анонимно - Далее - Эти данные включают в себя информацию об аппаратном и программном обеспечении вашего телефона, настройках приложения E-Rezept и объеме использования, но никогда не включают информацию о вас или вашем здоровье. - Обработчики данных предоставляют информацию только компании gematik GmbH и удаляют ее не позднее чем через 180 дней. Вы можете в любое время деактивировать анализ в меню приложения. - Эти данные позволяют нам понять, какие функции часто используются, и оптимизировать их. Кроме того, мы оцениваем, на протяжении какого времени требуется поддержка старого оборудования и когда, например, мы можем включить в системные требования более новую версию операционной системы, чтобы изменения затронули как можно меньше пользователей. - Улучшить приложение - Не соглашаюсь - Анонимный анализ остается деактивирован - %s Спасибо за вашу поддержку! - Заказать или зарезервировать - Рецепты автоматически отмечаются как выкупленные - Выкупленные рецепты могут отображаться в разделе \"Архив\" с задержкой. - OK - Для удаления рецептов необходимо войти в систему. - Сообщить об ошибке - Получено неверное сообщение - Аптека отправила сообщение в неправильном формате. - Сообщение об ошибке из приложения E-Rezept - Вы отправляете нам эту информацию для устранения неполадок. Обратите внимание, что вместе с ней будут переданы также ваш адрес электронной почты и, возможно, ваше имя, содержащееся в нем. Если вы не хотите передавать эту информацию полностью или частично, удалите ее из этого письма. \n\nКомпания gematik GmbH или ее подрядчик хранит и обрабатывает все данные только в целях обработки этого сообщения об ошибке. Удаление происходит автоматически, не позднее чем через 180 дней после обработки заявки. Мы используем ваш адрес электронной почты только для связи с вами по поводу этого сообщения об ошибке. С вопросами или просьбой о досрочном удалении данных вы можете в любое время связаться с ответственным по защите данных системы E-Rezept. Дополнительную информацию см. в меню приложения E-Rezept в разделе о защите данных. - Войти - Пройдите идентификацию для загрузки рецептов. - Выкуплен %s - Вы получили альтернативный препарат - Следуйте указаниям по применению в вашем плане приема лекарств или письменным инструкциям вашего врача по дозировке препарата. - Информация для аптек: это приложение получает контактные данные и информацию об аптеках с сайта mein-apothekenportal.de Немецкой ассоциации фармацевтов Deutscher Apothekerverband e.V. Вы обнаружили ошибку или хотите исправить данные? - Узнать больше - Аптеки - К сожалению, выполнить не удалось \uD83D\uDE15 - Попробуйте еще раз. - У вас есть вопросы или проблемы с использованием приложения? Телефон нашей горячей линии – %s. - Мы уже ответили на многие вопросы на сайте %s. - Ввести пароль - Далее - Вспомогательные инструменты - Изменение масштаба - Изменение размеров содержимого в окне приложения сведением или разведением пальцев на экране. - Указание - Пароль - Защитите свои данные, установив собственный пароль. - Пароль - Сохранить - Показать пароль - Ввести пароль - Вы можете использовать любые цифры, буквы и специальные символы. - Повторить пароль - Надежность пароля - Рекомендации: %s - Написать электронное письмо - Мы будем рады вашему отклику - Чем конкретнее, тем лучше - При отправке сообщения будет передана следующая информация об используемом аппаратном обеспечении и операционной системе: - Операционная система - Android %s (версия разработки %s) (последнее обновление системы безопасности %s) - Модель - %s %s (кодовое обозначение %s) - Режим - Темная тема - Светлая тема - Язык - Отправить - Обратная связь - Медицинская карточка - Понятно - Подать заявку на новую медицинскую карточку - Это приложение поможет вам подать заявку на новую медицинскую карточку. Оплата не требуется. - Выкуп будет возможен в ближайшее время - Эта аптека пока не может принимать электронные рецепты. - E-Rezept - Поддерживает работу с E-Rezept - Сейчас открыто - Курьерская доставка - Отправка - Фильтр - Предпочитаемые фильтры - Фильтры - Возможно, функция определения местоположения отключена в настройках. - Местоположение недоступно - Организация медицинского страхования - Страховой номер - Отправить электронное письмо - Выбрать организацию медицинского страхования - Проверьте введенные данные - Страховой номер + E-Rezept + OK + Отмена + Назад + в + Цифровизация. Оперативность. Надежность. + Добавить рецепты + Вы получили распечатанный рецепт? Для добавления рецептов в приложение отсканируйте код рецепта. + Понятно + ID задачи + Код доступа + Условия использования + Политика конфиденциальности + Рецепты + Доступ к камере запрещен + Для использования сканера необходимо разрешить приложению доступ к камере в системных настройках. + Сфокусируйте камеру на коде рецепта + Код рецепта недействителен + Этот код рецепта уже отсканирован + + Распознан %s рецепт + Распознано %s рецепта + Распознано %s рецептов + Распознано %s рецептов + + Отмена + Подсветка камеры + Прервать сканирование кодов рецептов? + Прервать сканирование + Продолжить + Давайте начнем + Вам потребуется: + Введите номер доступа + Ввести PIN-код + Попробовать снова + Не удалось подключиться к серверу. + Введен неправильный PIN-код. + + У вас осталась еще %s попытка, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попытки, прежде чем ваша карточка будет заблокирована. + У вас осталась еще %s попыток, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попыток, прежде чем ваша карточка будет заблокирована. + + Введен неправильный CAN + Номер доступа указан в верхнем правом углу медицинской карточки. + Отмена + Поиск карточки... + Приложите медицинскую карточку к обратной стороне устройства. + Поиск продолжается... + Медленно переместите карточку с обратной стороны устройства. + Совет + Чехол может помешать соединению через NFC. + Карточка распознана + Попытайтесь не двигать медицинскую карточку. + Медицинская карточка найдена. Не двигайте ее. + Соединение прервано + Снова приложите медицинскую карточку к обратной стороне устройства + Версия: %s + Хэш сборки: %s + Меню отладки + Код рецепта + Отсканируйте этот код рецепта в аптеке. + Этот сводный код объединяет %s рецепта/-ов + Выкупить в аптеке + Вы находитесь в аптеке и хотите получить препарат по рецепту. + Заказать или зарезервировать + Отправьте свой рецепт в аптеку и решите, как вы хотите получить препараты. + Разрешите определение местоположения и находите аптеки поблизости от вас + Разрешить определение местоположения + Открыто до %s + Открыто круглосуточно + Выходные данные + Издатель + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Руководитель: д-р мед. наук Маркус Лейк Дикен\nРегистрационный суд: участковый суд Берлин-Шарлоттенбург\nНомер в торговом реестре: HRB 96351\nИдентификационный номер плательщика НДС: DE241843684 + Ответственный за содержание + д-р мед. наук Маркус Лейк Дикен + Контактная информация + Указание + Мы стремимся использовать язык гендерного равенства. Если вы заметите какие-либо ошибки, мы будем рады получить от вас письмо по электронной почте. + Современная немецкая платформа цифровой медицины + Написать электронное письмо + Открыть сайт + Добро пожаловать + Начать процедуру входа + Разблокировать + Войти + Отмена + Безопасность + Юридическая информация + Выходные данные + Защита данных + Условия использования + Подробности + Отметить как выкупленный + Отметить как не выкупленный + Форма выпуска + стандартный размер + Застрахованное лицо + Фамилия + Адрес + Дата рождения + Организация медицинского страхования / плательщик + Статус + Страховой номер + Лицо, выписавшее рецепт + Фамилия + Врач + Номер врача (LANR) + Учреждение + Фамилия + Адрес + Номер учреждения + Номер телефона + Электронная почта + Производственная травма + Дата происшествия + Номер предприятия, на котором произошел несчастный случай, или номер работодателя + Вы хотите навсегда удалить этот рецепт? + Удалить + Отмена + Препараты-заменители допустимы. В соответствии с юридическими требованиями вашей организации медицинского страхования вам может быть выдан альтернативный препарат. + Зарезервировать + Запросить доставку курьером + Заказать отправку по почте + Примите во внимание, что за прописанные препараты может взиматься дополнительная плата. + Часы работы + Веб-сайт + Выкупить следующие рецепты в %s? + Можно выкупить в качестве самостоятельного плательщика только сегодня + Войти + Активировать NFC + Активируйте функцию NFC на своем устройстве, чтобы войти в систему со своей медицинской карточкой. + Активировать + Исправить + Показать отдельные коды + Показать сводный код + %s из %s + Рецепты выкуплены? + Отметить рецепты как выкупленные? + Не выкуплены + Выкуплены + Откроется в %s + +49 800 277 377 7 + Техническая горячая линия + Открыть сканер рецептов + Настройки + Указание + Это изменение вступит в силу только при следующем запуске приложения. + OK + Отключить скриншоты + Скрывать предпросмотр при смене приложения + Разрешаете ли вы приложению E-Rezept анонимно анализировать поведение пользователя? + Техническая информация + Защита данных ваших рецептов + Примите во внимание: если этим устройством вместе с вами пользуются другие люди, которые сохранили свои биометрические параметры на устройстве или знают его PIN-код, графический ключ или пароль, они могут получить доступ к вашим рецептам. + Не удалось отправить + Приложение электронной почты не настроено + Результаты не найдены + По данному поисковому запросу результаты не найдены. + Лицензии на ПО с открытым исходным кодом + Контактная информация + Позвонить на горячую линию + Принять участие в опросе + +49 800 277 377 7 + Узнать больше + Я хочу помочь в работе по улучшению этого приложения + Эти данные включают в себя информацию об аппаратном и программном обеспечении вашего телефона, настройках приложения E-Rezept и объеме использования, но никогда не включают информацию о вас или вашем здоровье. + Обработчики данных предоставляют информацию только компании gematik GmbH и удаляют ее не позднее чем через 180 дней. Вы можете в любое время деактивировать анализ в меню приложения. + Эти данные позволяют нам понять, какие функции часто используются, и оптимизировать их. Кроме того, мы оцениваем, на протяжении какого времени требуется поддержка старого оборудования и когда, например, мы можем включить в системные требования более новую версию операционной системы, чтобы изменения затронули как можно меньше пользователей. + Улучшить приложение + Анонимный анализ остается деактивирован + %s Спасибо за вашу поддержку! + Указание + При перемещении выкупленных рецептов в архив возможны задержки. + OK + Войти + Пройдите идентификацию для загрузки рецептов. + Выкуплен %s + Информация для аптек: это приложение получает контактные данные и информацию об аптеках с сайта mein-apothekenportal.de Немецкой ассоциации фармацевтов Deutscher Apothekerverband e.V. Вы обнаружили ошибку или хотите исправить данные? + Узнать больше + Аптеки + К сожалению, выполнить не удалось \uD83D\uDE15 + Попробуйте еще раз. + Ввести пароль + Далее + Вспомогательные инструменты + Изменение масштаба + Изменение размеров содержимого в окне приложения сведением или разведением пальцев на экране. + Указание + Пароль + Защитите свои данные, установив собственный пароль. + Пароль + Сохранить + Показать пароль + Повторить пароль + Рекомендации: %s + Написать электронное письмо + При отправке сообщения будет передана следующая информация об используемом аппаратном обеспечении и операционной системе: + Выкуп будет возможен в ближайшее время + Эта аптека пока не может принимать электронные рецепты. + Сейчас открыто + Курьерская доставка + Отправка + Фильтр + Фильтры + Местоположение недоступно + Понятно + Пароли совпадают + + Можно выкупить в качестве самостоятельного плательщика еще в течение %s дня + Можно выкупить в качестве самостоятельного плательщика еще в течение %s дней + Можно выкупить в качестве самостоятельного плательщика еще в течение %s дней + Можно выкупить в качестве самостоятельного плательщика еще в течение %s дней + + + Действует еще %s день + Действует еще %s дня + Действует еще %s дней + Действует еще %s дней + + Открыть сканер + Мы обрабатываем информацию о вашем устройстве!\nДля чтения кодов рецептов данное приложение использует Google ML Kit. Нажимая \"Принять\", вы разрешаете Google время от времени получать доступ к информации о вашем устройстве и обрабатывать ее для анализа использования, диагностики и настройки ML Kit. Вы можете в любое время отозвать свое согласие, что не повлияет на правомерность обработки информации до отзыва согласия. В случае отказа вы не сможете воспользоваться функцией сканирования рецептов. + Соглашаюсь + Отмена + Ошибка 20 10 76631 + Сертификат вашей медицинской карточки недействителен. Может быть, срок действия вашей карточки истек? Обратитесь в свою организацию медицинского страхования. + Безуспешные попытки входа + + Зафиксирована %s безуспешная попытка входа. + Зафиксировано %s безуспешных попытки входа. + Зафиксировано %s безуспешных попыток входа. + Зафиксировано %s безуспешных попыток входа. + + Выбрать наилучшую функцию защиты устройства + Это может быть отпечаток пальца, графический ключ и т.п. + Токены + Токен доступа + SSO-токен + Токен доступа недоступен + SSO-токен недоступен + копирование в буфер обмена выполнено + Нажмите, чтобы скопировать токен в буфер обмена + действительно только сегодня + Разрешить + Отсутствует соединение с сервером + Попробуйте снова через несколько минут + Перезагрузить + Показать токены + Как вы хотели бы защитить приложение? + Указание + На этом устройстве блокировка не установлена + Рекомендуем вам обеспечить дополнительную защиту своих медицинских данных путем блокировки устройства, например, с помощью кода или биометрической информации. + Не показывать больше это указание. + Не удалось установить соединение. Подключение к сети не выполнено. + Не удалось установить соединение с сервером: код состояния %s. + Не удалось установить соединение с сервером: ошибка VAU + Предупреждение + Возможно, этому устройству нельзя полностью доверять + В целях безопасности это приложение не следует использовать на устройствах с корневым доступом. + Я понимаю повышенный уровень риска и, несмотря на это, хочу продолжить. + Почему устройства с корневым доступом потенциально небезопасны? + Узнать больше + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + Имя профиля + Введите имя нового профиля. + Имя профиля + Профили + Добавить профиль + Медицинская карточка + Обратиться в организацию медицинского страхования + Для регистрации в этом приложении вам необходима медицинская карточка, поддерживающая функции NFC, и PIN к ней. + Вы можете бесплатно получить ее в своей организации медицинского страхования. Для этого вам потребуется подтвердить свою личность официальным документом. + Как определить, поддерживает ли медицинская карточка функции NFC + Выбрать организацию медицинского страхования + Не выбрано + Что вы хотели бы заказать? + Обратиться к ней через это приложение нельзя + Свяжитесь со своей страховой организацией по обычным каналам. + Медицинская карточка и PIN + Только PIN + Обратитесь в свою организацию медицинского страхования + Вход в приложение E-Rezept + Поле имени не может быть пустым. + Профиль с таким именем уже существует. + Профиль + %s выбран + Фоновый цвет + Весенний серый + Росянка + Это! Розовый! + Дерево + Синяя луна сентябрь + Вход не выполнен + Соединение установлено + Дата последнего соединения %s + Удалить профиль? + Все данные профиля на этом устройстве будут удалены. Ваши рецепты в сети здравоохранения сохранятся. + Удалить + Отмена + Удалить профиль + Вы собираетесь удалить последний профиль. + Приложению необходим как минимум один профиль. Введите имя нового профиля. + Ошибка 20 10 76831 + Не удалось получить доступ к списку медицинских карточек. Попытайтесь повторить позднее. + На национальном портале здравоохранения вы найдете проверенную специалистами информацию о болезнях, кодах МКБ, профилактике и уходе за больными. + Перейти по адресу gesund.bund.de + Мы внесли изменения в политику конфиденциальности + Приложение E-Rezept было усовершенствовано. Из-за этого потребовалось обновить нашу политику конфиденциальности. + Открыть политику конфиденциальности + Порядок изменился с %s: + Что происходит, когда вы открываете приложение? + Что происходит, когда я использую камеру / считываю рецепты с помощью камеры? + Выбрать профиль + Редактирование профилей + Новые рецепты недоступны + + рецепт обновлен + рецепта обновлено + рецептов обновлено + рецептов обновлено + + Можно выкупить + Осуществляется выкуп + Выкуплен + Неизвестно + Подробности + Показать протоколы доступа + Здесь вы можете увидеть, кто обращался к вашим рецептам + Это ключ для доступа к службе рецептов + Протоколы доступа + Нет протоколов доступа + Вы получите протоколы доступа, когда войдете в службу рецептов. + Протоколов доступа еще нет. + Дата последнего обновления %s + Рецепт в настоящее время обрабатывается и не может быть удален + Принять + Видимо, что-то пошло не так + Мы знаем, что при установлении соединения с медицинской карточкой могут возникать сложности. Поэтому в будущем планируем создать возможность входа в систему через приложение организации медицинского страхования, в котором аутентификация уже пройдена.\n\nМы работаем также над тем, чтобы рецепты можно было выкупать в электронной форме и без входа в систему.\n\nВы заметили что-нибудь во время этого процесса, о чем хотели бы сообщить нам? Мы будем рады вашим отзывам, даже очень критическим. + Советы по улучшению качества соединения + Улучшите качество соединения + Снимите чехол (при наличии). + Если устройство вибрирует, а затем соединение разрывается, найдите оптимальное положение в небольшом радиусе. + Очень медленно переместите устройство над карточкой. + Положите устройство непосредственно на карточку. + Для этого поместите медицинскую карточку на ровное основание (например, положите на стол). + Улучшите качество соединения + Примите во внимание местоположение датчика NFC + Выясните, где находится датчик NFC на вашем устройстве (см., например, обзор устройств %s). + В устройствах одного модельного ряда положение датчика NFC может варьироваться (см., например, информацию о %s). + Следующий совет + Далее + Закрыть + Протестировать + Напишите нам + Поиск аптек по лицензии + Выкупить + Отсканированный рецепт + Отсканирован %s + Отмечен как выкупленный %s + Как вы хотели бы продолжить? + Заказать + Будет доступно в ближайшее время + Зарезервировать сейчас для самовывоза или заказать доставку курьером либо отправку + Сохранить, чтобы заказать позднее + Сохранить рецепты на устройстве + + Продлжить с %s рецептом + Продолжить с %s рецептами + Продолжить с %s рецептами + Продолжить с %s рецептами + + Ошибка привязки медицинской карточки + Текущий профиль уже привязан к другой медицинской карточке (номер в системе социального страхования %s). + Ваша медицинская карточка уже привязана к другому профилю. Перейдите в профиль %s. + Мой заказ + Зарезервировать сейчас + Заказать сейчас + Сохранить + Контактная информация и адрес + Контактная информация + Номер телефона + Укажите номер телефона, чтобы с вами можно было связаться. + Адрес электронной почты (необязательно) + Адрес доставки + Имя и фамилия + Укажите имя и фамилию, чтобы с вами можно было связаться. + Улица и номер дома + Укажите улицу и номер дома, чтобы с вами можно было связаться. + Дополнительное поле для адреса (необязательно) + Почтовый индекс и населенный пункт + Укажите почтовый индекс и населенный пункт, чтобы с вами можно было связаться. + Указания по доставке (необязательно) + Ваш рецепт будет отправлен в указанную аптеку. После этого вы не сможете выкупить его в другой аптеке. + Контактные данные и адрес доставки + Рецепты + Ваши контактные данные необходимы для того, чтобы аптека могла проконсультировать вас и чтобы вы получали информацию о текущем статусе своего заказа. + Ввести контактные данные + Необходимы дополнительные контактные данные + Заказ успешно передан + Ваша аптека вскоре свяжется с вами. + Закрыть + Отменить изменения? + Очистить + Для поиска по списку аптек используются геокоординаты, полученные с помощью OpenStreetMap. Мы благодарим этот проект за поддержку. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + Использование и защита данных + Далее + PIN-код вы получили в письме от организации медицинского страхования. + PIN-код не получен + PIN-код + Проверьте соединение с Интернетом и настройки времени/даты на вашем устройстве. + Для аутентификации нажмите \"Разблокировать\". + Блокировка? Проверьте свои биометрические данные доступа на этом устройстве. + Забыли пароль? Удалите приложение и затем установите его заново. Причины мы объясняем в %s. + Раздел справки + размер упаковки и единица измерения + Действующее вещество + Количество действующего вещества + Обозначение партии + Годен до + Категория + Вакцина + Принять + Отменить + Указание + Поможете нам сделать это приложение лучше? + Установить собственный пароль + Пароль должен состоять как минимум из восьми символов + Надежность пароля недостаточная + Надежность пароля достаточная + Показывать пароль + Не показывать пароль + Биометрия + Пароль + Ожидание ответа + Нет рецептов + В настоящее время у вас нет рецептов, которые можно выкупить. + Обновить + Автоматический выход из системы + В целях безопасности соединение с сервером рецептов разрывается через 12 часов. Установите соединение заново, чтобы запросить актуальные рецепты. + Установить соединение + Вы получили распечатку? + Добавьте рецепты в свой список, нажав кнопку сканирования в правом верхнем углу. + Отсканировать распечатку + Чтобы получать рецепты автоматически, необходимо войти в систему. + Войти + Нет выкупленных рецептов + Здесь отображаются ваши выкупленные рецепты. В целях защиты данных ваши рецепты удаляются с сервера рецептов через 100 дней. + Нет выкупленных рецептов + Здесь отображаются ваши выкупленные рецепты. Отсканируйте новые рецепты, чтобы начать их выкуп. + Управление устройствами + Привязанные устройства + Дата регистрации %s (данное устройство) + Дата регистрации %s + Актуальные + Архив + Выкупить заново? + Указание: аптека, принимающая рецепт первой, блокирует его обработку другими аптеками. + Отмена + OK + + Вы уже отправили в аптеку %s рецепт. Все равно выкупить заново? + Вы уже отправили в аптеку %s рецепта. Все равно выкупить заново? + Вы уже отправили в аптеку %s рецептов. Все равно выкупить заново? + Вы уже отправили в аптеку некоторые из этих рецептов. Все равно выкупить заново? + + В целях безопасности соединение с сервером рецептов прерывается через 12 часов. Для повторного подключения вам потребуется карта здоровья и PIN-код для каждого процесса подключения. + PIN-код + Введите PIN-код (карточки здоровья) + Далее + Аутентификация + Привязанные устройства + Удалить устройство? + Отмена + Удалить + Удалить это устройство? + Вы хотите удалить %s? + Если вы удалите %s, не позднее чем через 12 часов соединение с сервером рецептов будет разорвано на длительное время. + Устройства загружаются... + Нет устройств + К этой медицинской карточке не привязано ни одно устройство. + Попробовать снова + Ох-ох:-( + Не удалось загрузить список устройств. + Нет соединения + Соединение с Интернетом отсутствует. + Препараты и перевязочные средства + Наркотические средства + Отпуск медикаментов, выдаваемых по рецепту, согласно § 4 постановления о рецептах на лекарственные препараты + Вам требуется помощь? + Мы подобрали ряд советов по решению наиболее часто встречающихся проблем. + Показать советы по привязке + Отсканирован: %s + Отсканированный рецепт + Разблокировать + Карточка заблокирована + PIN-код был введен неверно три раза, поэтому ваша карточка заблокирована в целях безопасности. + Разблокировать карточку + Ввести PUK-код + Вместе с PIN-кодом вы получили от своей страховой организации 8-значный PUK-код. + Выбрать новый PIN-код + Новый индивидуальный идентификационный номер (PIN-код) вы можете выбрать самостоятельно (от 6 до 8 символов). + Запомнили PIN-код? + Запишите свой PIN-код и сохраните записку в надежном месте. + Отмена + Введен неправильный PUK-код. + OK + Деблокировка невозможна + Вы достигли максимального количества операций деблокировки с помощью этого PUK-кода либо повторно ввели неправильный код. Обратитесь в свою страховую организацию. + Вы можете использовать PUK-код максимум для 10 операций деблокировки. + Карточка разблокирована + Вам потребуется: + Ваша медицинская карточка + PUK-код вашей медицинской карточки + Далее + Медицинская карточка + Заказать новую карточку + Войти + Получайте рецепты онлайн и направляйте их в аптеку. + Медицинская карточка с поддержкой NFC + PIN-код медицинской карточки + У вас еще нет медицинской карточки с поддержкой NFC и PIN-кода? + Заказать сейчас + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + Или: войдите в систему с помощью %s. + Приложение вашей организации медицинского страхования + \"Номер доступа (CAN) находится на вашей медицинской карточке в правом верхнем углу\". + У моей карточки нет номера доступа + + У вас осталась еще %s попытка, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попытки, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попыток, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попыток, прежде чем ваша карточка будет заблокирована. + + Приложите медицинскую карточку к обратной стороне телефона + Следующая процедура может занять до 30 секунд. + Приложите карточку %s к обратной стороне телефона. + вверху справа + вверху посередине + вверху слева + в центре справа + в центре + в центре слева + внизу справа + внизу посередине + внизу слева + Справка + Отправлено %s минут(ы) назад + Отправлено %s + Отправлено только что + Отправлено в %s + Недействительно + Войти с помощью приложения + Выбрать страховую организацию + Не удалось найти? Список постоянно дополняется. Функцию входа с помощью медицинской карточки поддерживают уже все организации медицинского страхования. + Обратная связь от приложения E-Rezept + Мы будем рады вашим откликам. Введите в поле, расположенное ниже, максимально точные формулировки: + PUK-код + Закрыть + Как жаль… + К сожалению, ваше устройство не соответствует минимальным требованиям для входа в приложение E-Rezept. Для безопасной аутентификации с помощью вашей медицинской карточки требуется как минимум Android 7 и чип NFC. + Узнать больше + Сохранить данные доступа? + Сохранить + Не сохранять + Указание + В целях безопасности соединение с сервером рецептов прерывается через 12 часов. Для повторного подключения вам потребуется карта здоровья и PIN-код для каждого процесса подключения. + Настроить биометрическую защиту + Сохранение данных доступа невозможно. Сначала настройте на своем устройстве биометрическую защиту (например, с помощью отпечатка пальца). + Отмена + Настройки + Указание + Принять + Защита данных ваших рецептов + \"Это приложение использует самый надежный биометрический датчик, имеющийся на вашем устройстве, для обеспечения безопасности ваших данных доступа в защищенном разделе памяти устройства. \" + Биометрическая защита ваших данных доступа позволяет в будущем без помощи медицинской карточки и ввода PIN-кода открывать это приложение, а также просматривать, запрашивать, выкупать и удалять рецепты. + Примите во внимание: если этим устройством вместе с вами пользуются другие люди, которые сохранили свои биометрические параметры на устройстве или знают его PIN-код, графический ключ или пароль, они могут получить доступ к вашим рецептам. + К сожалению, выполнить не удалось + Аутентификация с помощью приложения организации медицинского страхования не пройдена. + Срок истек %s + Рецепт уже удален с сервера + Пожалуйста, исправьте введенные данные или отмените изменения + Исправить + Данные застрахованного лица + Фамилия + Страховая организация + Страховой номер + Номер доступа (CAN) + Войти + Выйти + Чтобы получать рецепты автоматически, необходимо войти в систему. + Сохранить + Изменять + Изменить изображение профиля + Далее + Сервер не отвечает + Повторите попытку позже. + Попробовать снова + Ищите страховку + Подключиться к серверу рецептов сейчас? + Вы успешно вошли в систему + соединение потеряно + Подключиться к серверу рецептов сейчас? + Нет токенов + Вы получите токен, когда войдете в службу рецептов.\n + заказы + Выберите нужный PIN-код + Разблокировать карточку + Выберите PIN-код + Повторите PIN-код + Введенные данные не совпадают. + Нет заказов + У вас пока нет заказов. + Только что + В %s часов + Корзина готова + Рецепт добавлен в корзину. Пожалуйста, перейдите на сайт аптеки, чтобы завершить заказ. + Открыть корзину + Покажите этот код самовывоза в аптеке. + Получен код самовывоза + Сообщение не может быть отображено + Пожалуйста, свяжитесь с вашей аптекой ( %s ). + Показать ссылку корзины + Показать код самовывоза + Показать сообщение + %s в %s часов + Рецепт отправлен %s . + Обзор заказа + Новый + Курс + Заказ + Бесплатно для звонящего. Время работы: пн-пт с 8:00 до 20:00 кроме государственных праздников + Аптека + Выберите нужный PIN-код + Желаемый PIN-код сохранен + Невозможно сохранить желаемый PIN-код + E-Rezept + В настоящее время открыто и рядом со мной + Сортировать по … + начать поиск + Отмена + Будет искуплен для вас + прямое назначение + Выкупить + Телефонный номер (не обязательно) + Поиск по имени или адресу + Нет достоверной информации об аптеке + Актуальной информации об этой аптеке не найдено. Запись об этой аптеке будет удалена. + OK + Справочник аптек недоступен + В настоящее время невозможно получить текущую информацию об этой аптеке. Пожалуйста, проверьте подключение к интернету. + Отмена + Попробовать снова + Сохранить окружающую среду + Вход невозможен + Похоже, ваши биометрические данные для входа изменились. Пожалуйста, зарегистрируйтесь снова с вашей картой здоровья. + Отмена + Войти + профиль 1 + Близко ко мне + У них нет погашаемых рецептов + Можно использовать позже + Можно получить от %s + %s / %s + улучшения продукта + Анонимный анализ + Помогите нам сделать это приложение лучше. Все пользовательские данные собираются анонимно и используются только для улучшения пользовательского опыта. + безопасность устройства + персональные настройки + Вспомогательные инструменты + улучшения продукта + Добавлен рецепт + Рецепт уже доступен + Произошла ошибка при импорте + Удалить + Отсканированный рецепт + Возможна замена препарата + Отправлено %s в %s + Погашено %s в %s + Забыли PIN-код + + %s рецепт + + + %s Рецепты + + Я прочитал и принимаю политику конфиденциальности и условия использования. + Политика конфиденциальности + Условия эксплуатации + Мы хотели бы: + Улучшить удобство использования. + Обнаружение ошибок и сбоев. + Все данные, разумеется, собираются анонимно. + Вы можете в любое время изменить это решение в системных настройках. + Продолжить + Принять + Это приложение использует самый безопасный метод, предоставляемый вашим устройством. + Сохранить + Выбирать + Препарат + торговое название + Да + нет + дозировка + Дата выпуска + ночь + Этот рецепт будет выкуплен для вас как часть лечения. + Нет данных + Нет платы за экстренную помощь + дополнительный платеж + Препарат + Накладные + Соответствует требованиям BVG + альтернативная подготовка + название рецепта + Упаковка + инструкция по изготовлению + описание + данный + выпущено: + Действующее вещество + предписанный + Получать + Что такое прямое назначение? + В случае прямых направлений рецепт из практики или больницы выкупается непосредственно в аптеке. Застрахованные не обязаны предпринимать никаких действий и не могут вмешиваться в процесс выкупа. \n\n Прямые направления перечислены в приложении электронных рецептов, чтобы сделать ваше лечение более прозрачным для вас. + Нет платы за экстренную помощь + Спешите порядок дня здесь. Этот рецепт также можно получить ночью в аптеке без дополнительной оплаты сбора за экстренную помощь. + Препараты, подлежащие доплате + Освобожден от доплаты + Те, у кого есть государственная медицинская страховка, должны внести доплату в размере до десяти евро за лекарства, отпускаемые по рецепту. \n\n Размер доплаты зависит от стоимости вашего лекарства. Вы должны платить за лекарства стоимостью менее 5 евро самостоятельно.\n За более дорогие лекарства вы должны заплатить десять процентов от цены, но не менее 5 евро и не более 10 евро. \n\n Дети и молодые люди в возрасте до 18 лет, как правило, освобождаются от доплаты. \n\n Если ваши ежегодные расходы на лекарства превышают ваш финансовый лимит, вы можете быть освобождены от доплаты. Поговорите об этом со своей страховой компанией. + Вы освобождаетесь от доплаты за этот препарат. Ваша медицинская страховка покроет стоимость лекарства. + Как долго действует этот рецепт? + В течение этого периода вы можете выкупить рецепт в любой аптеке за дополнительную плату. + В течение этого периода вы все еще можете выкупить рецепт в аптеке, но вам придется заплатить покупную цену самостоятельно. Кроме того, вы можете попросить свою практику выписать рецепт еще раз. + Возможна замена препарата + В соответствии с юридическими требованиями вашей медицинской страховой компании вам может быть предоставлена альтернатива с тем же активным ингредиентом. \n\n Лекарства могут выглядеть и называться по-разному, иметь разную цену и производителя, но при этом содержать одно и то же действующее вещество. Сам активный ингредиент и дозировка особенно важны для действия лекарств в организме. Пациенты в аптеке часто получают другой препарат, чем тот, который выписал врач по рецепту, при условии, что препараты сопоставимы. Для изменения могут быть терапевтические и экономические причины. + Отсканированный рецепт + Из соображений безопасности рецепты, импортированные из бумажной распечатки, не должны содержать никаких личных или медицинских данных. \n\n Войдите в это приложение с медицинской картой или страховым приложением, чтобы просмотреть всю информацию, содержащуюся в рецепте. + Неверный рецепт + Этот рецепт был выписан неправильно. + Отсканированный рецепт + плата за экстренную помощь + Дозировка в соответствии с письменными инструкциями + Телефон + сайт + Электронная почта + Сортировка по расстоянию невозможна. + OK + Введите текущий PIN-код + Введен неверный PIN-код + Текущий PIN-код вашей карты здоровья + Карточка заблокирована + Разблокируйте карту в меню «Настройки > «Разблокировать карту». + В целях безопасности введите текущий PIN-код. + Забыли PIN-код + Неправильный рецепт + Препарат + Похоже, что-то пошло не так при создании вашего рецепта. Сообщить об ошибке? + Сообщить + Вход не выполнен + Зарегистрировано с + Медицинская карточка + Биометрия + Вход не выполнен + Нам интересно ваше мнение. Пожалуйста, найдите пять минут, чтобы ответить на вопросы нашего опроса. Заранее спасибо. + предупреждение + Аптека добавлена в избранное + Убрал аптеку из избранного + Мои аптеки + Надежность пароля очень высокая + Операция записи не удалась + PIN-код не может быть сохранен + Сообщить + Назначить PIN-код + Нарушено правило доступа + У вас нет разрешения на доступ к каталогу карт. + Назначьте свой собственный пин + Карта защищена PIN-кодом от вашей медицинской страховой компании (транспортный PIN-код), пожалуйста, присвойте свой собственный PIN-код. + Пароль не найден + На вашей карте не хранится пароль. + Вы вышли из системы + Войдите еще раз, чтобы обновить свои рецепты. + номер активного ингредиента + мощь и единство diff --git a/android/src/main/res/values-tr/strings.xml b/android/src/main/res/values-tr/strings.xml index 77cd5c4f..7e568c92 100644 --- a/android/src/main/res/values-tr/strings.xml +++ b/android/src/main/res/values-tr/strings.xml @@ -1,471 +1,769 @@ - E-Rezept - Tamam - İptal et - Geri - Saat: - Saat %1$s - Son güncelleme: %1$s - Güncelleme başarısız. Lütfen reçetelerinizi tekrar güncelleyin. - Dijital. Hızlı. Güvenli. - E-Rezept uygulamasına hoş geldiniz - Burada elektronik reçeteleri seçtiğiniz bir eczanede, doğrudan sitede veya çevrim içi olarak kullanabilirsiniz. - Sağlık kartınızla daha fazla fonksiyon - Yeni reçetelerinizi otomatik olarak güncelleyin - İlaçlarınızın alınması ve dozajları hakkında bilgi - Eczanenizden siparişinizle ilgili bildirimler alın - Kullanım koşulları ve veri koruma politikası - Uygulamayı kullanmak için lütfen kullanım koşullarını kabul edin ve veri koruma politikasından haberdar olduğunuzu onaylayın. Yalnızca hizmetlerin çalışması için gerekli olan veriler toplanır. - %s\'nı okudum ve kabul ediyorum. - Kullanım koşulları - Veri koruması politikası - Onayla - İleri - Onayla - Reçete ekle - Bir reçete çıktısı mı aldınız? İlgili reçete kodunu tarayarak reçetleri uygulamaya ekleyebilirsiniz. - Anlaşıldı - Task-ID - Erişim kodu - Kopyalandı - Kullanım şartları - Veri koruma politikası - Kullanım koşullarını kabul et - Veri koruma politikasını kabul et - Reçeteler - Reçeteler - Bildirimler - Kullan - - Daha %s gün geçerli - Daha %s gün geçerli - - Örneğin dermatolog - %s %s / %s %s algılandı. Daha fazla kod taransın mı? - - Reçete - Reçeteler - - - Reçete - Reçeteler - - - %s reçete ekle - %s reçete ekle - - Kameraya erişim reddedildi - Tarayıcıyı kullanabilmek için sistem ayarlarında uygulamanın kameranıza erişmesine izin vermelisiniz. - Kamerayı bir reçete koduna odaklayın - Bu geçerli bir reçete kodu değil - Bu reçete kodu zaten taranmış - - %s reçete algılandı - %s reçete algılandı - - İptal et - Kamera ışığı - Reçete kodlarının taranması iptal edilsin mi? - Taramayı iptal et - Devam et - Kart ekle - İşte başlıyoruz - Şimdi tüm fonksiyonları kullanın - Uygulamanın tüm fonksiyonlarını kullanabilmek için sağlık kartınız ile giriş yapın. Bu kartı ve gerekli erişim verilerini sağlık sigortanızdan alacaksınız. - İhtiyacınız olan şey: - Erişim numarası (CAN) olan bir sağlık kartı - Sağlık kartının PIN\'i - Kart ekle - Ne yazık … - Maalesef cihazınız E-Rezept uygulamasına giriş yapmak için gereken minimum gereksinimleri karşılamıyor. - Sağlık kartına kaydolmak için neden minimum gereksinimler var? - Kart erişim numaranız (Card Access Number, kısaca: CAN) 6 hanelidir. CAN\'ı sağlık sigortası kartınızın ön yüzünün sağ üst köşesinde bulacaksınız. Burada altı haneli bir erişim numarası yoksa sağlık sigortanızdan yeni bir sağlık kartına ihtiyacınız olacaktır. - Erişim numarasını girin - İstediğiniz rakamı girebilirsiniz. - PIN\'iniz 6 ila 8 basamaklı olabilir. - PIN girin - Demo modunda herhangi bir PIN girebilirsiniz. - Tekrar dene - Şimdi elektronik sağlık kartınızı hazırlayın. - Cihazınızın sunucuya bağlanması için geçen süre, donanım ve internet hızına bağlı olarak değişebilir. - Sunucu bağlantısı başarısız oldu. - İnternet bağlantınızı kontrol edin ve işlemi yeniden başlatın. - Yanlış PIN girildi. - - Kartınız bloke olmadan %s denemeniz kaldı. - Kartınız bloke olmadan %s denemeniz kaldı. - - Yanlış CAN girildi - Erişim numarasını sağlık kartınızın sağ üst köşesinde bulacaksınız. - PIN birkaç kez yanlış girildi. - Sağlık kartınızın blokesi PUK ile açılmalıdır. - İptal et - Kartı ara... - Sağlık kartını cihazınızın arkasına doğru tutun. - Hala aranıyor … - Kartı cihazın arkasında yavaşça hareket ettirin. - İpucu - Cihaz kılıfları, NFC üzerinden bağlantıyı zorlaştırabilir. - Kart algılandı - Sağlık kartını hareket ettirmemeye çalışın. - Sağlık kartı bulundu. Lütfen hareket etmeyin. - Bağlantı koptu - Sağlık kartınızı tekrar cihazınızın arkasına doğru tutun. - Başarıyla oturum açtınız - Not: Yalnızca son 100 güne ait reçeteler indirilir. - Demo modu etkinleştirildi - NFC özellikli bir sağlık kartınız var ve bunu demo modunda denemek ister misiniz? - Kart ile devam et - Kartsız devam et - Demo modu etkinleştirildi - Sürüm: %s - Build-Hash: %s - Hata ayıklama menüsü - Reçete kodu - Bu reçete kodunu eczanenizde tarattırın. - Bu toplu kod %s reçete birleştirir - Eczanede kullan - Bir eczanedesiniz ve reçetenizi kullanmak istiyorsunuz. - Sipariş ver veya rezerve et - Reçetenizi bir eczaneye gönderin ve ilacınızı nasıl almak istediğinize karar verin. - Bunun için geçerli bir sağlık kartına ihtiyacınız var. - Eczaneyi seç - Örneğin Pinguin Apotheke veya adres - Eczaneleri kolayca bulun - Konumunuzu paylaşın ve bölgenizdeki eczaneleri bulun - Konumu paylaş - Şu saate kadar açık: %s - Tüm gün açık - Künye - Editör - gematik GmbH\nFriedrichstraße 136\n10117 Berlin - Genel Müdür: Dr. med. Markus Leyck Dieken\n Kayıt mahkemesi: Amtsgericht Berlin-Charlottenburg\n Ticaret sicil no.: HRB 96351\n Satış vergisi kimlik numarası: DE241843684 - İçerikten sorumlu - Dr. med. Markus Leyck Dieken - İletişim - Not - Cinsiyet eşitliğine uygun bir dil kullanmaya çalışıyoruz. Herhangi bir hata fark ederseniz, sizden e-posta ile haber almaktan memnuniyet duyarız. - Taranmış reçete - İlaç %s - Güncel - Güncelle - Arşiv - Henüz herhangi bir reçete kullanmadınız - - %s ilaç - %s ilaç - - %s tarihinde kullanıldı - Henüz herhangi bir reçete kullanmadınız - Almanya\'nın modern dijital tıp platformu - E-posta yaz - Web sitesini aç - Hoş geldiniz - Oturum açmayı başlat - Kilidi Aç\'a basın - Kilidini aç - Uygulamayı kullanırken herhangi bir sorunuz veya sorununuz mu var? Teknik destek hattımıza %s numarasından ulaşılabilirsiniz.\n%s sayfamızda sizin için birçok soruyu halihazırda yanıtladık. - Oturum aç - İptal et - https://www.das-e-rezept-fuer-deutschland.de/ - das-e-rezept-fuer-deutschland.de - Ayarlar - Adı bilinmiyor - Sağlık kartları - Kart ekle - Denemek için - Demo modu, elektronik sağlık kartı olmadan bile uygulamanın tüm alanlarını keşfetmenize olanak tanır. - Demo modu - Güvenlik - Sağlık bilgilerinizi yetkisiz erişime karşı koruyun. - Güvenli değil - Önerilmez - Biyometri - Bu uygulama, cihazınız tarafından sağlanan en güvenli biyometrik sensörü kullanır. - Cihaz yedekleme - Önerilmez - Yasal - Künye - Veri koruma - Kullanım koşulları - Demo modu etkinleştirildi - Demo modumuz size uygulamanın tüm fonksiyonlarını gösterir - hem de sağlık kartı olmadan. - Bir keşif turuna ne dersiniz? - Demo modumuz size uygulamanın tüm fonksiyonlarını gösterir - hem de sağlık kartı olmadan. - Demo modunu başlat - Güncel reçeteniz yok - Reçete verilerini yedekle - Verilerinizin parmak izi veya yüz taramasıyla daha iyi korunması. - Şimdi aktifleştir - Ayrıntılar - Genel bakış elde edin - İlacınızı alır almaz bu reçeteyi kullanılmış olarak işaretleyin. - Reçeteleri otomatik olarak güncelle - Reçetelerinizin otomatik olarak kullanılmış olarak işaretlenebilmesi için oturum açın. - Şimdi oturum aç - Neden sadece bu bilgileri görüyorum? - Sağlık bilgileriniz özel korumaya sahiptir - İlaç %1$d - Kullanıldı olarak işaretle - Kullanılmadı olarak işaretle - Bu cihazdan sil - Protokol - Şu tarihte tarandı: - Saat %1$s - %s tarihine kadar kullanılabilir - Bu ilaçla ilgili ayrıntılar - İlaç türü - Paket boyutu - İlaç merkezi numarası (PZN) - Kullanım talimatları - Lütfen ilaç planınızdaki alım talimatlarına veya doktorunuzun yazılı dozaj talimatlarına uyun. - Sigortalı kişi - Ad - Adres - Doğum tarihi - Sağlık sigortası / ödeyenler - Durum - Sigorta numarası - Reçete yazan kişi - Ad - Uzman doktor - Doktor numarası (LANR) - Kurum - Ad - Adres - Kuruluş numarası - Telefon numarası - E-posta - İş kazası - Kaza günü - Kaza şirketi veya işveren numarası - Bu reçeteyi kalıcı olarak silmek ister misiniz? - Sil - İptal et - Yalnızca bu reçeteyi mi yoksa tüm reçeteleri tekrar kullanıma sunmak mı istiyorsunuz? - Tümünü - Yalnızca bunu - Bu acil bir durum - Bu ilaç, acil servis ücreti olmadan geceleri eczanede de kullanılabilir. - Muadil mümkün - Muadillere izin verilir. Sağlık sigortanızın yasal gereklilikleri nedeniyle size bir alternatif verilebilir. - Bağlayıcı bir rezerve et - Kurye hizmeti talep edin - Kargo ile teslim ettir - Reçeteli ilaçlar için ek ödemelerin de geçerli olabileceğini lütfen unutmayın. - Açılış saatleri - Web sitesi - Rezervasyon - Bu reçeteleri %s eczanesinde bağlayıcı olarak kullanmak istiyor musunuz? - Reçeteler - Kullan - Kurye hizmeti - Teslimat adresi - Nasıl yardımcı olabiliriz? - Teslimat adresi mi değişti? Eczaneye başka bir mesajınız var mı? - Şimdi ara - Teslimat adresinizi çevrim içi eczanenin web sitesinde değiştirebilirsiniz. - Kargo - Protokol - Ayarlanmış eylem olmadan - Süresi dolmuş - Sadece bugün geçerli - Reçete bloğunu yeniden adlandır - Bu reçete bloğu için bir ad atayabilirsiniz. - Oturum aç - Oturum aç - En az Android 7\'ye sahip NFC özellikli bir akıllı telefon - NFC\'yi etkinleştir - Sağlık kartınız ile oturum açmak için lütfen cihazınızın NFC fonksiyonunu etkinleştirin. - Etkinleştir - Yeni bir sağlık kartını nasıl alabilirim? - Sağlık sigortanız bu konuda size yardımcı olur. - Nasıl PIN alabilirim? - Sağlık sigorta şirketinizden sağlık kartınızın PIN\'i için ayrı bir mektup alacaksınız. - Erişim verilerinizi gelecekteki oturum açmalar için kaydetmek ister misiniz? - Erişim verilerini kaydet - Konforlu: Bu amaçla verileriniz cihazda biyometrik olarak korunacaktır - Yedekleme mümkün değil - Güvenli sensör yok veya biyometrik yedekleme kurulmadı. - Erişim verilerini kaydetme - Veri tasarrufu: Uygulamayı her başlattığınızda erişim verilerinizi girmenizi gerektirir - Düzelt - Ana sayfaya git - Şu tarihte kullanıldı olarak işaretlendi: - Şu tarihte kullanılmadı olarak işaretlendi: - Tekli kodlar olarak göster - Toplu kod olarak göster - %s / %s - Reçeteler kullanıldı mı? - Bu reçeteleri kullanılmış olarak işaretlemek ister misiniz? - Kullanılmadı - Kullanıldı - Şu saatte açılıyor: %s - +49 800 277 377 7 - Teknik destek hattı - Reçeteler için tarayıcıyı açın - Ayarlar - +49 800 277 377 7 - Lütfen parmak izi veya yüz tanıma yoluyla kimliğinizi doğrulayın. - Not - Bu değişiklik, yalnızca uygulamayı yeniden başlattıktan sonra geçerli olacaktır. - Tamam - İzleme - Bu uygulamayı daha iyi hale getirmemize yardımcı olun. Tüm kullanım verileri anonim olarak toplanır ve yalnızca kullanıcı deneyimini iyileştirmek için hizmet verir. - İzlemeye izin ver - Uygulamada bir çökme veya hata olması durumunda uygulama bize nedenleri hakkında bilgi gönderir. Ayrıca işletim sistemi sürümü ve kullanılan donanımlar ile ilgili bilgiler de gönderilir. - Ekran görüntülerini gizle - Uygulamaları değiştirdiğinizde önizleme görüntüsünün görüntülenmesini engeller - E-Rezept\'in kullanıcı davranışınızı anonim olarak analiz etmesine izin veriyor musunuz? - Bu, telefonunuzun donanım ve yazılım bilgilerini, E-Rezept uygulamasının ayarlarını ve kullanım kapsamını içerir, ancak asla kişiliğiniz veya sağlığınızla ilgili verileri içermez.\nVeriler, veri işleyenler tarafından sadece gematik GmbH\'ye sunulur ve en geç 180 gün sonra silinir. Analizi istediğiniz zaman uygulama menüsünden devre dışı bırakabilirsiniz.\nBu veriler, hangi fonksiyonların sıklıkla kullanıldığını anlamamızı ve bunları geliştirmemizi sağlar. Ayrıca, daha eski teknolojinin ne kadar süreyle desteklenmesi gerektiğini ve örneğin ne zaman (çok fazla) kullanıcıyı etkilemeden daha yeni bir işletim sistemi sürümünü zorunlu hale getirmemiz gerektiğini tahmin edebiliriz. - İzin ver - - Sizin için %s ilaç reçetelendirildi - Sizin için %s ilaç reçetelendirildi - - Eczanede kullanmak için buraya dokunun - Şimdi kullan - Tümünü göster - Reçeteyi sil - Kullanıldı olarak işaretlendi - Geri al - Daha fazla göster - Daha az göster - Teknik bilgiler - Oturumu kapat - Sağlık ağına tüm erişim verileri silinecektir. Reçete verileriniz korunur. - Bu, erişim verilerinizi siler. - Oturumu kapat - İptal et - Uygulamadan çıkmak ister misiniz? - Reçete verilerinizin güvenliği - Lütfen bu cihazı paylaşabileceğiniz ve biyometrik özellikleri bu cihazda saklanabilecek veya cihaz PIN\'ini, kaydırma hareketini veya şifreyi bilen kişilerin de reçetelerinize erişebileceğini unutmayın. - Bağlayıcı olarak kullan? - Bu vesileyle reçeteleriniz bu eczaneye gönderilecektir. Daha sonra bunları artık başka bir eczanede kullanamazsınız. - İptal et - Şimdi kullan - Başarıyla kullanıldı - Eczane teslimat ayrıntılarını netleştirmek için en kısa sürede sizinle iletişime geçecektir. - Siparişinizi tarayıcıda tamamlayın - Ana sayfaya geç - Çevrim içi eczanesi ilaçlarınızla birlikte bir alışveriş sepeti oluşturacaktır. Bu işlem birkaç dakika sürebilir. - \"Alışveriş sepetini aç\"a dokunun ve eczanenin web sitesinde siparişinizi tamamlayın. - Ana sayfaya git - Gönderim başarısız oldu - Tekrarla - Siparişiniz genellikle kısa sürede teslim almanız için hazırdır. Kesin randevu için lütfen eczane ile irtibata geçin. - Alışveriş sepetiniz hazır - Teslim alma kodu al - Bildirim alındı - Teslim alma kodunu göster - Alışveriş sepetini aç - Bu kodu eczanenizde gösterin. - Teslim alma kodu - Bildirim yok - Henüz herhangi bir bildirim almadınız - Maalesef eczanenizden gelen mesaj boştu. Lütfen eczanenizle iletişime geçin. - E-posta programı kurulmamış - Sonuç yok - Bu arama terimi ile herhangi bir sonuç bulamadık. - Open Source Lisansları - İletişim - Teknik destek hattını ara - E-posta yaz - Ankete katıl - +49 800 277 377 7 - Daha fazla bilgi - Gülümseyen aile - Ezcaneci elinde akıllı telefonunu ile sizi bekliyor. - Elinde akıllı telefonu var ve yeni elektronik sağlık kartı ile uygulamada kimliğini doğruluyor. - Bu uygulamayı daha iyi hale getirmek için bize yardımcı olun - Biz şunu istiyoruz: - Kullanabiliriği iyileştirmek için %s uygulamasındaki kullanıcı akışlarını analiz etmek. - Donmaları ve hata bildirimlerini %s geliştiricilere göndermek. - Teknik destek hattını iyileştirmek için hataları erkenden tespit etmek. - Bu uygulamayı daha iyi hale getirmek için yardımcı olmak istiyorum - Bu kararı sistem ayarlarında her zaman değiştirebilirsiniz. - anonim - İleri - Bu, telefonunuzun donanım ve yazılım bilgilerini, e-reçete uygulamasının ayarlarını ve kullanım kapsamını içerir, ancak asla kişiliğiniz veya sağlığınızla ilgili verileri içermez. - Veriler, veri işleyenler tarafından sadece gematik GmbH\'ye sunulur ve en geç 180 gün sonra silinir. Analizi istediğiniz zaman uygulama menüsünden devre dışı bırakabilirsiniz. - Bu veriler, hangi işlevlerin sıklıkla kullanıldığını anlamamızı ve bunları geliştirmemizi sağlar. Ayrıca, daha eski teknolojinin ne kadar süre desteklenmesi gerektiğini ve örneğin (çok fazla) kullanıcıyı etkilemeden daha yeni bir işletim sistemi sürümünü ne zaman zorunlu hale getirebileceğimizi de değerlendirebiliriz. - Uygulamayı iyileştir - Reddet - Anonim analiz devre dışı kalıyor - %s Desteğiniz için teşekkürler! - Sipariş ver veya rezerve et - Reçeteler otomatik olarak kullanılmış olarak işaretlenir - Kullanılan tariflerin \"Arşiv\" alanında görüntülenmesinde bir gecikme olabilir. - Tamam - Reçete silmek için oturum açmanız gerekir. - Hatayı bildir - Hatalı bildirim alındı - Bir ezcane hatalı formata sahip bir bildirim gönderdi. - E-Rezept uygulamasından hata bildirimi - Bu bilgileri bize sorun giderme amacıyla gönderiyorsunuz. Lütfen e-posta adresinizin ve varsa burada yer alan adınızın da aktarılacağını unutmayın. Bu bilgilerin tamamını veya bir kısmını iletmek istemiyorsanız, lütfen bu mailden siliniz.\n\ngematik GmbH veya anlaşmalı olduğu şirket, tüm verileri yalnızca bu hata mesajını işlemek için depolar ve işler. Silme işlemi, biletin işlenmesinden en geç 180 gün sonra otomatik olarak yapılır. E-posta adresinizi yalnızca bu hata mesajıyla ilgili olarak sizinle iletişim kurmak için kullanıyoruz. Sorularınız veya erken silme için dilediğiniz zaman E-Rezept sisteminin veri koruma görevlisi ile iletişime geçebilirsiniz. Daha fazla bilgiyi e-Rezept uygulamasında veri koruma girişinin altındaki menüde bulabilirsiniz. - Oturum aç - Uygulamayı indirmek için lütfen kimliğinizi doğrulayın. - %s tarihinde kullanıldı - Bir muadil teslim aldınız - Lütfen ilaç planınızdaki alım talimatlarına veya doktorunuzun yazılı dozaj talimatlarına uyun. - Eczaneler için not: Bu uygulama eczanelerin iletişim bilgilerini ve bilgilerini Alman Eczacılar Birliği e.V.\'nin mein-apothekenportal.de adresinden alır. Bir hata mı keşfettiniz veya verileri düzeltmek mi istiyorsunuz? - Daha fazla bilgi - Ezcaneler - Bu maalesef olmadı \uD83D\uDE15 - Lütfen tekrar deneyin. - Uygulamayı kullanırken herhangi bir sorunuz veya sorununuz mu var? Teknik destek hattımıza %s numarasından ulaşılabilirsiniz. - %s sayfamızda sizin için birçok soruyu halihazırda yanıtladık. - Şifreyi girin - İleri - Kullanım yardımı - Yakınlaştırma - Parmaklarınızı bir araya getirmek veya ayırmak uygulamayı büyütmenizi sağlar (yakınlaştırmak için sıkıştırın). - Not - Şifre - Verilerinizi kendiniz seçtiğiniz şifre ile koruyun. - Şifre - Kaydet - Şifreyi göster - Şifreyi girin - İstediğiniz rakamı, harfi veya özel karakteri girebilirsiniz. - Şifreyi tekrarla - Şifre güçlüğü - Öneriler: %s - E-posta yaz - Geri bildiriminizi bekliyoruz - Ne kadar ayrıntılı, o kadar iyi - Mesajınızı gönderdiğinizde, kullanılan donanım ve işletim sistemi ile ilgili aşağıdaki bilgiler iletilecektir: - İşletim sistemi - Android %s (geliştirici sürümü %s) (son güvenlik güncellemesi %s) - Model - %s %s (kod adı %s) - Mod - Karanlık mod - Aydınlık mod - Dil - Gönder - Geri bildirim - Sağlık kartı - Anlaşıldı - Yeni sağlık kartı için başvur - Bu uygulama, yeni bir elektronik sağlık kartı başvurusunda bulunmanıza yardımcı olur. Bunun için sizden hiçbir üçret talep edilmez. - Kullanmak yakında mümkün - Bu ezcane henüz e-reçete kabul edemiyor. - E-reçete - E-reçete için hazır - Şu an açık - Kurye hizmeti - Kargo - Filtre - Favori filtreler - Filtrele - Konum paylaşımı muhtemelen ayarlardan devre dışı bırakılmış olabilir. - Konumu mevcut değil - Sağlık sigortası - Sigorta numarası - E-posta gönder - Sağlık sigortanızı seçin - Lütfen girişinizi tekrar kontrol edin - Sigorta numarası + E-Rezept + Tamam + İptal et + Geri + Saat: + Dijital. Hızlı. Güvenli. + Reçete ekle + Bir reçete çıktısı mı aldınız? İlgili reçete kodunu tarayarak reçetleri uygulamaya ekleyebilirsiniz. + Anlaşıldı + Task-ID + Erişim kodu + Kullanım şartları + Veri koruma politikası + Reçeteler + Kameraya erişim reddedildi + Tarayıcıyı kullanabilmek için sistem ayarlarında uygulamanın kameranıza erişmesine izin vermelisiniz. + Kamerayı bir reçete koduna odaklayın + Bu geçerli bir reçete kodu değil + Bu reçete kodu zaten taranmış + + %s reçete algılandı + %s reçete algılandı + + İptal et + Kamera ışığı + Reçete kodlarının taranması iptal edilsin mi? + Taramayı iptal et + Devam et + İşte başlıyoruz + İhtiyacınız olan şey: + Erişim numarasını girin + PIN girin + Tekrar dene + Sunucu bağlantısı başarısız oldu. + Yanlış PIN girildi. + + Kartınız bloke olmadan %s denemeniz kaldı. + Kartınız bloke olmadan %s denemeniz kaldı. + + Yanlış CAN girildi + Erişim numarasını sağlık kartınızın sağ üst köşesinde bulacaksınız. + İptal et + Kartı ara... + Sağlık kartını cihazınızın arkasına doğru tutun. + Hala aranıyor … + Kartı cihazın arkasında yavaşça hareket ettirin. + İpucu + Cihaz kılıfları, NFC üzerinden bağlantıyı zorlaştırabilir. + Kart algılandı + Sağlık kartını hareket ettirmemeye çalışın. + Sağlık kartı bulundu. Lütfen hareket etmeyin. + Bağlantı koptu + Sağlık kartınızı tekrar cihazınızın arkasına doğru tutun. + Sürüm: %s + Build-Hash: %s + Hata ayıklama menüsü + Reçete kodu + Bu reçete kodunu eczanenizde tarattırın. + Bu toplu kod %s reçete birleştirir + Eczanede kullan + Bir eczanedesiniz ve reçetenizi kullanmak istiyorsunuz. + Sipariş ver veya rezerve et + Reçetenizi bir eczaneye gönderin ve ilacınızı nasıl almak istediğinize karar verin. + Konumunuzu paylaşın ve bölgenizdeki eczaneleri bulun + Konumu paylaş + Şu saate kadar açık: %s + Tüm gün açık + Künye + Editör + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Genel Müdür: Dr. med. Markus Leyck Dieken\n Kayıt mahkemesi: Amtsgericht Berlin-Charlottenburg\n Ticaret sicil no.: HRB 96351\n Satış vergisi kimlik numarası: DE241843684 + İçerikten sorumlu + Dr. med. Markus Leyck Dieken + İletişim + Not + Cinsiyet eşitliğine uygun bir dil kullanmaya çalışıyoruz. Herhangi bir hata fark ederseniz, sizden e-posta ile haber almaktan memnuniyet duyarız. + Almanya\'nın modern dijital tıp platformu + E-posta yaz + Web sitesini aç + Hoş geldiniz + Oturum açmayı başlat + Kilidini aç + Oturum aç + İptal et + Güvenlik + Yasal + Künye + Veri koruma + Kullanım koşulları + Ayrıntılar + Kullanıldı olarak işaretle + Kullanılmadı olarak işaretle + İlaç türü + standart beden + Sigortalı kişi + Ad + Adres + Doğum tarihi + Sağlık sigortası / ödeyenler + Durum + Sigorta numarası + Reçete yazan kişi + Ad + Uzman doktor + Doktor numarası (LANR) + Kurum + Ad + Adres + Kuruluş numarası + Telefon numarası + E-posta + İş kazası + Kaza günü + Kaza şirketi veya işveren numarası + Bu reçeteyi kalıcı olarak silmek ister misiniz? + Sil + İptal et + Muadillere izin verilir. Sağlık sigortanızın yasal gereklilikleri nedeniyle size bir alternatif verilebilir. + Bağlayıcı bir rezerve et + Kurye hizmeti talep edin + Kargo ile teslim ettir + Reçeteli ilaçlar için ek ödemelerin de geçerli olabileceğini lütfen unutmayın. + Açılış saatleri + Web sitesi + %s\'da bulunan reçeteleri bağlayıcı olarak kullanmak istiyor musunuz? + Sacede bugün ve sadece kendiniz ödeyerek kullanabilirsiniz + Oturum aç + NFC\'yi etkinleştir + Sağlık kartınız ile oturum açmak için lütfen cihazınızın NFC fonksiyonunu etkinleştirin. + Etkinleştir + Düzelt + Tekli kodlar olarak göster + Toplu kod olarak göster + %s / %s + Reçeteler kullanıldı mı? + Bu reçeteleri kullanılmış olarak işaretlemek ister misiniz? + Kullanılmadı + Kullanıldı + Şu saatte açılıyor: %s + +49 800 277 377 7 + Teknik destek hattı + Reçeteler için tarayıcıyı açın + Ayarlar + Not + Bu değişiklik, yalnızca uygulamayı yeniden başlattıktan sonra geçerli olacaktır. + Tamam + Ekran görüntülerini gizle + Uygulamaları değiştirdiğinizde önizleme görüntüsünün görüntülenmesini engeller + E-Rezept\'in kullanıcı davranışınızı anonim olarak analiz etmesine izin veriyor musunuz? + Teknik bilgiler + Reçete verilerinizin güvenliği + Lütfen bu cihazı paylaşabileceğiniz ve biyometrik özellikleri bu cihazda saklanabilecek veya cihaz PIN\'ini, kaydırma hareketini veya şifreyi bilen kişilerin de reçetelerinize erişebileceğini unutmayın. + Gönderim başarısız oldu + E-posta programı kurulmamış + Sonuç yok + Bu arama terimi ile herhangi bir sonuç bulamadık. + Open Source Lisansları + İletişim + Teknik destek hattını ara + Ankete katıl + +49 800 277 377 7 + Daha fazla bilgi + Bu uygulamayı daha iyi hale getirmek için yardımcı olmak istiyorum + Bu, telefonunuzun donanım ve yazılım bilgilerini, e-reçete uygulamasının ayarlarını ve kullanım kapsamını içerir, ancak asla kişiliğiniz veya sağlığınızla ilgili verileri içermez. + Veriler, veri işleyenler tarafından sadece gematik GmbH\'ye sunulur ve en geç 180 gün sonra silinir. Analizi istediğiniz zaman uygulama menüsünden devre dışı bırakabilirsiniz. + Bu veriler, hangi işlevlerin sıklıkla kullanıldığını anlamamızı ve bunları geliştirmemizi sağlar. Ayrıca, daha eski teknolojinin ne kadar süre desteklenmesi gerektiğini ve örneğin (çok fazla) kullanıcıyı etkilemeden daha yeni bir işletim sistemi sürümünü ne zaman zorunlu hale getirebileceğimizi de değerlendirebiliriz. + Uygulamayı iyileştir + Anonim analiz devre dışı kalıyor + %s Desteğiniz için teşekkürler! + Not + Kullanılan reçetlerin Arşiv alanında görüntülenmesi biraz zaman alabilir. + Tamam + Oturum aç + Reçeteyi indirmek için lütfen kimliğinizi doğrulayın. + %s tarihinde kullanıldı + Eczaneler için not: Eczanelerin iletişim bilgilerini ve bilgilerini Deutscher Apothekenverband e.V.\'ın mein-apothekenportal.de adresinden alıyoruz. Bir hata mı buldunuz veya verileri düzeltmek mi istiyorsunuz? + Daha fazla bilgi + Ezcaneler + Bu maalesef olmadı \uD83D\uDE15 + Lütfen tekrar deneyin. + Şifreyi girin + İleri + Kullanım yardımı + Yakınlaştırma + Parmaklarınızı bir araya getirmek veya ayırmak uygulamayı büyütmenizi sağlar (yakınlaştırmak için sıkıştırın). + Not + Şifre + Verilerinizi kendiniz seçtiğiniz şifre ile koruyun. + Şifre + Kaydet + Şifreyi göster + Şifreyi tekrarla + Öneriler: %s + E-posta yaz + Mesajınızı gönderdiğinizde, kullanılan donanım ve işletim sistemi ile ilgili aşağıdaki bilgiler iletilecektir: + Kullanmak yakında mümkün + Bu ezcane henüz e-reçete kabul edemiyor. + Şu an açık + Kurye hizmeti + Kargo + Filtre + Filtrele + Herhangi bir konum mevcut değil + Anladım + Tekrarlanan şifre eşleşiyor + + Kendiniz ödeyerek %s gün daha geçerli + Kendiniz ödeyerek %s gün daha geçerli + + + %s gün daha geçerli + %s gün daha geçerli + + Tarayıcıyı aç + Cihaz bilgilerinizi işliyoruz! Bu uygulama, reçete kodunu okumak için Google\'ın ML Kit\'ini kullanır. \"Kabul Et\"i seçerseniz, Google\'ın zaman zaman cihaz bilgilerine erişebileceğini ve bunları ML Kit\'in kullanım analizi, teşhisi ve yapılandırması amacıyla işleyebileceğini kabul edersiniz. Gerçekleşen işlemenin yasallığını etkilemeden onayınızı istediğiniz zaman iptal etme hakkına sahipsiniz. Ancak bu ret, reçete kodu tarayıcısının kullanılamamasına neden olacaktır. + Kabul ediyorum + İptal et + Hata 20 10 76631 + Sağlık kartınızın sertifikası geçerli değil. Kartınızın süresi dolmuş olabilir mi? Lütfen sağlık sigortanız ile iletişime geçin. + Başarısız oturum açma denemesi + + %s başarısız oturum açma denemesi tespit edildi. + %s başarısız oturum açma denemesi tespit edildi. + + En iyi cihaz emniyetini seçme + Bu, bir parmak izi, bir silme deseni veya benzeri olabilir + Tokenler + Access Token + SSO Token + Herhangi bir Access Token mevcut değil + Herhangi bir SSO Token mevcut değil + Ara belleğe kopyalandı + Token\'i ara belleğe kopyalamak için tıklayın + Sadece bugün geçerli + İzin ver + Sunucuya bağlantı yok + Lütfen birkaç dakika sonra tekrar deneyin. + Yeniden yükle + Tokenleri göster + Bu uygulamayı nasıl güven altına almak istiyorsunuz? + Not + Bu cihaz için cihaz emniyeti kurulmamış + Medikal verilerinizi kod veya biyometri gibi ilave cihaz emniyeti ile korumanızı öneririz. + Bu notu gelecekte gösterme. + Bağlantı başarısız. Ağa bağlantı kurulamadı. + Sunucu ile iletişim başarısız oldu: %s durum kodu. + Sunucu ile iletişim başarısız: VAU hatası + Uyarı + Bu cihaza tam güvenilmemeli + Bu uygulama güvenlik nedenlerden dolayı kök erişimi bulunan cihazlarda kullanılmamalıdır. + Yüksek riski kabul ediyor ve yine de devam etmek istiyorum. + Kök erişimi bulunan cihazlar neden potansiyel bir güvenlik riski taşıyor? + Daha fazla bilgi + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + Profilin adı + Yeni profil için bir ad girin. + Profil adı + Profiller + Profil ekle + Sağlık kartı + Sağlık sigortanız ile iletişime geçin + Bu uygulamaya giriş yapabilmek için NFC özellikli bir sağlık kartına ve buna ait bir PIN\'e ihtiyacınız var. + Bunu ücretsiz olarak sağlık sigortanızdan temin edebilirsiniz. Bunun için kimliğiniz, resmi kimlik belgeniz ile doğrulanmış olmalıdır. + Bu şekilde NFC özellikli sağlık kartını tespit edebilirsiniz + Sağlık sigortanızı seçin + Seçim yok + Neye başvurmak istiyorsunuz? + Bu uygulama üzerinden iletişim kurmak mümkün değil. + Lütfen sigortanız ile iletişime geçmek için genel geçerli iletişim kanallarını kullanın. + Sağlık kartı ve PIN + Yalnızca PIN + Sağlık sigortanız ile iletişime geçin + E-Rezept uygulamasında oturum açın + Ad alanı boş olamaz. + Girilen ad ile halihazırda bir profil mevcut. + Profil + %s seçili + Arka plan rengi + Bahar grisi + Güneş gülleri + Bu! Pembe! + Ağaç + Mavi Ay Eylül + Oturum açılmamış + Bağlı + Son bağlanma tarihi: %s + Profili sil + Bununla birlikte bu cihazdaki tüm verileri silinecek. Sağlık ağındaki reçeteler muhafaza edilecek. + Sil + İptal et + Profili sil + Son profili silmek istiyorsunuz. + Uygulamada en az bir profil gerekli. Lütfen yeni profil için bir ad girin. + Hata 20 10 76831 + Sağlık kartı dizinine ulaşılamadı. Lütfen tekrar deneyin. + Das Nationale Gesundheitsportal\'da hastalıklar, ICD kodları ve önleme ve bakım konularında uzmanlar tarafından doğrulanmış bilgileri bulabilirsiniz. + gesund.bund.de aç + Veri koruma politikasını değiştirdik + E-Rezept uygulaması kendini geliştirdi. Bu nedenle veri koruma politikasının güncellenmesi gerekli oldu. + Veri koruma politikasını aç + %s tarihinden bu yana bunlar değişti: + Uygulamayı açıtığınızda neler oluyor? + Kamera fonksiyonunu kullanırsam/reçeteleri kamera ile tararsam neler oluyor? + Profil seç + Profili düzenle + Herhangi bir yeni reçete mevcut değil + + %s reçete güncellendi + %s reçete güncellendi + + Kullanılabilir + Reçete aktarıldı + Kullanıldı + Bilinmiyor + Ayrıntılar + Erişim protokollerini göster + Burada reçetelerinize kimlerin eriştiğini görebilirsiniz + Burada reçete hizmetine olan erişim anahtarı söz konusudur + Erişim protokolleri + Herhangi bir erişim protokolü yok + Reçete hizmetlerine kaydolduysanız erişim protokolleri alırsınız. + Halihazırda erişim protokolleri bulunmuyor. + Son güncelleme tarihi: %s + Reçete şu an düzenlenmekte ve silinemez + Kabul et + Bu maalesef başarılı olmadı + Sağlık kartıyla bağlantının bazı sorunların olduğunun farkındayız. Bu nedenle gelecekte, halihazırda kimliği doğrulanmış bir sağlık sigortası uygulaması aracılığıyla kayıt da mümkün olacaktır.\n\nAyrıca reçetelerin kayıt olmadan dijital olarak kullanılabilmesi için çalışıyoruz.\n\nBu süreçte bizimle paylaşmak istediğiniz bir şey mi fark ettiniz? Bu süreçte bizimle paylaşmak istediğiniz bir şey fark ettiniz mi? Lütfen bize yazın, son derece eleştirisel geri bildirimlerde bulunmanızdan da memnuniyet duyarız. + Bağlantı ip uçları + Bağlantıyı güçlendirin + Gerekirse koruyucu kılıfı çıkarın. + Cihaz titrer ve ardından bağlantı kesilirse daha küçük bir alanda en iyi konumu arayın. + Cihazı kartın üzerinde yavaşça hareket ettirin. + Cihazı doğrudan kartın üzerine koyun. + Bunun için sağlık kartını düz bir zeminin üzerine koyun (ör. masa). + Bağlantıyı güçlendirin + NFC sensörün konumlandırılmasını dikkate alın + Cihazınızda NFC sensörünüzün nerede bulunduğunu tespit edin (burada örn. %s marka cihazlar için bir genel bakış bulabilirsiniz). + Bazı durumlarda NFC sensörünüzün konumu model serisi dahilinde farklılık gösterebilir (burada örn. %s modeli için bilgiler bulabilirsiniz). + Bir sonraki ip ucu + İleri + Kapat + Deneyin + Bizimle iletişime geçin + Lisanslı eczane araması + Kullan + Taranmış reçete + Şu tarihte tarandı: %s + Şu tarihte kullanıldı olarak işaretlendi: %s + Nasıl devam etmek istiyorsunuz? + Sipariş ver + Yakında kullanıma sunulur + Teslim almak için şimdi rezerve edin veya bir kurye hizmeti ya da kargo ile teslim ettirin + Sonraki siparişler için kaydedin + Reçeteleri cihaza kaydedin + + %s reçete ile devam + %s reçete ile devam + + Sağlık kartının bağlanması başarısız oldu + Güncel profil halihazırda bir başka sağlık kartı (sağlık sigorta numarası %s) ile bağlantılı. + Sağlık kartınız halihazırda bir başka profil ile bağlantılı. %s profiline geçin. + Siparişim + Şimdi rezerve et + Şimdi sipariş ver + Kaydet + İletişim verileri ve adres + İletişim + Telefon numarası + İletişime geçmek için bir telefon numarası girin. + E-posta adresi (opsiyonel) + Teslimat adresi + Ad ve soyad + İletişime geçmek için bir ad ve soyad girin. + Sokak ve bina numarası + İletişime geçmek için bir sokak ve bina numarası girin. + Adres eki (opsiyonel) + Posta kodu ve yer + İletişime geçmek için bir posta kodu ve yeri girin. + Teslimat talimatları (opsiyonel) + Bu onay ile reçeteniz bu eczaneye gönderilecektir. Ardından bir başka eczanede kullanamazsınız. + İletişim verileri ve teslimat adresi + Reçeteler + Eczaneden danışma hizmeti almanız ve sizi siparişinizin güncel durumu hakkında bilgilendirmek için iletişim verileriniz gerekli. + İletişim bilgilerini gir + Daha fazla iletişim bilgileri gerekli + Sipariş başarıyla aktarıldı + Eczaneniz yakında sizinle iletişime geçecektir. + Kapat + Değişiklikler silinsin mi? + Sil + Ezcane dizini, arama için OpenStreetMap\'in koordinatlarını kullanır. Bu projeye yardımları için teşekkürlerimizi sunarız. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + Kullanım ve veri koruma + İleri + PIN\'iniz posta yoluyla sağlık sigortanızdan aldınız. + PIN alınmadı + PIN + Lütfen internet bağlantınızı ve cihazınızın saat ile tarih ayarlarını kontrol edin. + Kimliğinizi doğrulamak için \"Blokeyi kaldır\" üzerine basın. + Engellendiniz mi? Lütfen bu cihazdaki biyometrik erişim verilerinizi tekrar kontrol edin. + Şifreyi mi unuttunuz? Lütfen uygulamayı silin ve ardından yeniden yükleyin. Bunun neden böyle olduğunu şuradan öğrenebilirsiniz: %s. + Yardım alanı + paket boyutu ve birimi + Etken madde + Etken madde miktarı + Parti adı + Son kullanma tarihi: + Kategori + Aşı + Kabul et + Geri al + Not + Bu uygulamayı daha iyi hale getirmek için bize yardımcı olur musunuz? + Kendi parolanızı seçin + Parola en az sekiz basamaklı olmalıdır + Parola yeterince güçlü değil + Parola yeterince güçlü + Şifre görnünür + Şifre görünmez + Biyometri + Parola + Cevap bekliyor + Reçete yok + Şu an kullanabileceğiniz herhangi bir reçete yok. + Güncelle + Otomatik oturum kapatma + Güvenlik nedenlerinden dolayı reçete sunucusuna olan bağlantı 12 saat sonra kesilir. Güncel reçeteleri görüntülemek için lütfen tekrar bağlanın. + Bağlan + Size bir kağıt çıktı mı verildi? + Listenize reçeteler ekleyin. Bunun için sağ üst köşedeki tarama düğmesine tıklayın. + Kağıt çıktıyı tara + Reçeteleri otomatik olarak almak için oturum açmalısınız. + Oturum aç + Kullanılan reçete yok + Burada kullanılmış reçeteleriniz gösterilir. Reçeteleriniz reçete sunucusundan 100 gün sonra veri koruma nedenlerinden dolayı silinir. + Kullanılan reçete yok + Burada kullanılmış reçeteleriniz gösterilir. Kullanmaya başlamak için reçetelerinizi tarayarak ekleyin. + Cihaz yönetimi + Bağlı cihazlar + %s tarihinden beri kayıtlı (bu cihaz) + %s tarihinden beri kayıtlı + Güncel + Arşiv + Tekrar kullanılsın mı? + Uyarı: Reçeteni ilk olarak kabul eden ezcane diğer ezcanaler için reçeteyi bloke eder. + İptal et + Tamam + + %s reçetesini zaten bir ezcaneye yolladınız. Yine de yeniden kullanmak istiyor musunuz? + Bu reçetelerin bazılarını zaten bir ezcaneye yolladınız. Yine de başka eczaneye yollamak istiyor musunuz? + + Güvenlik nedeniyle, reçete sunucusuna bağlantı 12 saat sonra sonlandırılır. Yeniden bağlanmak için her bağlantı işlemi için bir sağlık kartına ve PIN\'e ihtiyacınız vardır. + PIN + PIN\'inizi (sağlık kartı) girin. + İleri + Kimlik doğrulama + Bağlı cihazlar + Cihazı çıkarmak istiyor musunuz? + İptal et + Çıkar + Bu cihazı çıkarmak istiyor musunuz? + %s çıkarmak istiyor musunuz? + %s çıkarırsanız en geç 12 saat sonra reçete sunucusu ile bağlantı devamlı olarak kesilir. + Cihazlar yükleniyor... + Cihaz yok + Bu sağlık kartı ile hiçbir cihaz bağlı değil. + Tekrar deneyin + Hay aksi :-( + Cihaz listesi yüklenemedi. + Bağlantı yok + İnternet bağlantısı yok + İlaç ve pansuman + Uyuşturucu madde + Madde 4 AMVV\'ye göre reçeteli ilaçların teslimi + Yardım mı istiyorsunuz? + En sık karşılaştığınız sorunları çözmek için sizin için birkaç ip uçu derledik. + Bağlantı ip uçlarını başlat + Şu tarihte tarandı: %s + Taranmış reçete + Blokeyi kaldır + Kart bloke edildi + PIN üç kez yanlış girildi. Güvenlik nedenlerinden dolayı kartınız bloke edildi. + Kartın blokesini kaldırın + PUK girin + PIN\'iniz ile sigortanızdan 8 haneli bir PUK aldınız. + Yeni PIN seçin + Yeni kişisel kimlik numaranızı (PIN) kendiniz seçebilirsiniz (6 ila 8 haneli). + PIN\'i hafızanıza kaydettiniz mi? + Lütfen PIN\'inizi not alın ve güvenli bir yerde saklayınız. + İptal et + Yanlış PUK girildi. + Tamam + Blokenin kaldırılması mümkün değil + Bu PUK ile maksimum kart bloke kaldırma sayısına ulaştınız veya tekrar yanlış girdiniz. Lütfen sigortanız ile iletişime geçin. + Bu PUK\'u en fazla 10 bloke kaldırma işlemi için kullanabilirsiniz. + Kartın blokesi kaldırıldı + Şunlara ihtiyacınız var: + Sağlık kartınız + Sağlık kartınızın PUK\'u + İleri + Sağlık kartı + Yeni bir kart sipariş edin + Oturum aç + Reçeteleri çevrim içi alın ve bir ezcaneye iletin. + NFC özellikli sağlık kartı + Sağlık kartının PIN\'i + NFC özellikli sağlık kartına ve PIN\'e henüz sahip değil misiniz? + Şimdi sipariş verin + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + Veya: %s ile oturum açın. + Sağlık sigortanızın uygulaması + "Erişim numaranızı (Card Access Number, kısaca: CAN) sağlık kartınızın sağ üst köşesinde bulacaksınız." + Kartımın erişim numarası yok + + Kartınız bloke edilmeden önce %s denemeniz var. + Kartınız bloke edilmeden önce %s denemeniz var. + + Sağlık kartını telefonun arkasına yerleştirin. + Aşağıdaki proses 30 saniye sürebilir. + Kartı %s telefonun arkasına yerleştirin. + Sağ üst alanda + Orta üst alanda + Sol üst alanda + Sağ orta alanda + ortada + Sol orta alanda + Sağ alt alanda + Orta alt alanda + Sol alt alanda + Yardım + %s dakika önce gönderildi + Şu saatte gönderildi: %s + Şu anda gönderildi + Şu saatte gönderildi: %s + Artık geçerli değil + Uygulama ile oturum aç + Sigortayı seç + Aradığınızı bulamadınız mı? Bu liste devamlı geliştiriliyor. Sağlık kartı ile oturum açılması halihazırda her sağlık sigortası tarafından desteklenmektedir. + E-Rezept uygulamasından geri bildirimi + Geri bildiriminizi bekliyoruz. Lütfen aşağıdaki alanı kullanın ve mümkün olduğunca detaylı yazın: + PUK + Kapat + Ne yazık … + Maalesef cihazınız E-Rezept uygulamasına giriş yapmak için gereken minimum gereksinimleri karşılamıyor. Sağlık kartınızla güvenli bir kimlik doğrulama için en az Android 7 ve NFC özellikli bir çip gerekli. + Daha fazla bilgi + Erişim verileri kaydedilsin mi? + Kaydet + Kaydetme + Not + Güvenlik nedeniyle, reçete sunucusuna bağlantı 12 saat sonra sonlandırılır. Yeniden bağlanmak için her bağlantı işlemi için bir sağlık kartına ve PIN\'e ihtiyacınız vardır. + Biyometrik güvenlik önlemi ayarla + Erişim verilerini kaydetmek mümkün değil. Cihazınızda önceden biyometrik bir güvenlik önlemi (ör. parmak izi) ayarlayın. + İptal et + Ayarlar + Not + Kabul et + Reçete verilerinizin güvenliği + \"Bu uygulama cihazınızın kullanıma sunduğu en güvenli biyometrik sensörünü kullanıyor. Bu şekilde erişim verileriniz cihazınızın hafızasında güvenli bir alanda kaydedilir. \" + Erişim verilerinizin biyometrik olarak kaydedilmesi, bu uygulamaya bundan sonra sağlık kartı olmadan ve PIN girmeden giriş yapılmasına, reçetelerin görüntülenmesine, açılmasına, kullanılmasına veya silinmesine olanak sağlar. + Bu cihazı başka kişilerle paylaşıyorsanız, biyometrik özellikleri bu cihazda saklanan kişilerin veya cihaz PIN\'ini, kaydırma hareketini veya şifreyi bilen kişilerin de reçetelerinize erişebildiğini lütfen dikkate alın. + Bu maalesef olmadı + Sağlık sigortası ile kimlik doğrulama başarılı olmadı. + %s tarihinde süresi doldu + Tarif zaten sunucudan silinmiş + Lütfen girişinizi düzeltin veya değişiklikleri atın + Düzelt + sigortalı veri + Ad + sigorta + Sigorta numarası + Erişim numarası (CAN) + Oturum aç + Oturumu kapat + Reçeteleri otomatik olarak almak için oturum açmalısınız. + Kaydet + Değiştirmek + Profil resmini düzenle + İleri + sunucu yanıt vermiyor + Lütfen daha sonra tekrar deneyiniz. + Tekrar deneyin + sigorta ara + Tarif sunucusuna şimdi bağlanılsın mı? + Başarıyla giriş yaptı + bağlantı koptu + Tarif sunucusuna şimdi bağlanılsın mı? + jeton yok + Reçete hizmetine giriş yaptığınızda bir jeton alacaksınız.\n + emirler + İstediğiniz PIN\'i seçin + Kartın blokesini kaldırın + PIN\'i seçin + PIN\'i tekrarla + Girişler birbirinden farklıdır. + sipariş yok + Henüz siparişiniz yok. + Şu anda + %s saatinde + Alışveriş sepeti hazır + Tarif alışveriş sepetinize eklendi. Siparişi tamamlamak için lütfen eczanenin web sitesine gidin. + Alışveriş sepetini aç + Bu teslim alma kodunu eczanede gösterin. + Teslim alma kodu alındı + Mesaj gösterilemiyor + Lütfen eczanenizle iletişime geçin ( %s ). + Sepet bağlantısını göster + Teslim alma kodunu göster + mesajı göster + %s saat %s yönünde + Tarif %s adresine gönderildi. + Siparişe genel bakış + Yeni + Kurs + Bir sipariş + Arayan için ücretsiz. Servis saatleri: Pazartesi - Cuma 08:00 - 20:00 Ulusal tatiller hariç + eczane + İstediğiniz PIN\'i seçin + İstenen PIN kaydedildi + İstenen PIN\'i kaydetmek mümkün değil + E-Rezept + Şu anda açık ve yakınımda + Tarafından filtre … + Aramaya başla + İptal et + Senin için kurtarılacak + doğrudan atama + Kullan + telefon numarası (isteğe bağlı) + Ada veya adrese göre arama + Geçerli eczane bilgisi yok + Bu eczane hakkında güncel bilgi bulunamadı. Bu eczane için giriş silinecek. + Tamam + Eczane rehberi mevcut değil + Şu anda bu eczane hakkında güncel bilgi alınamıyor. Lütfen internet bağlantınızı kontrol edin. + İptal et + Tekrar deneyin + Ortamı Kaydet + Oturum açılamaz + Biyometrik oturum açma bilgileriniz değişmiş gibi görünüyor. Lütfen sağlık kartınızla tekrar kayıt olun. + İptal et + Oturum aç + profil 1 + Bana yakın + Kullanılabilir reçeteleri yok + Daha sonra kullanılabilir + %s kullanılabilir + %s / %s + ürün iyileştirmeleri + Anonim Analiz + Bu uygulamayı daha iyi hale getirmemize yardımcı olun. Tüm kullanıcı verileri anonim olarak toplanır ve yalnızca kullanıcı deneyimini geliştirmek için kullanılır. + cihaz güvenliği + kişisel ayarlar + Kullanım yardımı + ürün iyileştirmeleri + Tarif eklendi + Tarif zaten mevcut + İçe aktarılırken bir hata oluştu + Sil + Taranmış reçete + Muadil mümkün + %s tarihinde %s tarihinde gönderildi + %s tarihinde %s tarihinde kullanıldı + PIN\'i unuttum + + %s Tarif + %s Tarifler + + Gizlilik politikasını ve kullanım koşullarını okudum ve kabul ediyorum. + Veri koruma politikası + Kullanım Şartları + İsteriz: + Kullanılabilirliği geliştirin. + Hataları ve çökmeleri tespit edin. + Tüm veriler elbette anonim olarak toplanır. + Bu kararı sistem ayarlarında her zaman değiştirebilirsiniz. + Devam et + Kabul et + Bu uygulama, cihazınız tarafından sağlanan en güvenli yöntemi kullanır. + Kaydet + Seçmek + uyuşturucu + ticari unvan + Evet + hayır + dozaj + Veriliş tarihi + gece + Bu reçete sizin için bir tedavinin parçası olarak kullanılacaktır. + Bilgi yok + Acil servis ücreti yok + Ek ödeme + uyuşturucu + Teslimat notları + BVG\'ye göre uygun + alternatif hazırlık + tarif adı + Ambalajlama + işçiliği talimatı + tanım + tarafından verilen + üzerinde yayınlanan: + Etken madde + reçete + Almak + Doğrudan atama nedir? + Doğrudan sevk durumunda, bir muayenehaneden veya hastaneden alınan reçete doğrudan eczanede kullanılır. Sigortalılar herhangi bir işlem yapmak zorunda değildir ve itfa sürecine müdahale edemezler. \n\n Tedavinizi sizin için daha şeffaf hale getirmek için e-reçete uygulamasında doğrudan yönlendirmeler listelenir. + Acil servis ücreti yok + Acele burada günün sırasıdır. Bu reçete ayrıca, ek bir acil servis ücreti ödemeden eczanede geceleri de doldurulabilir. + Katkı payına tabi ilaçlar + Ortak ödemeden muaf + Yasal sağlık sigortası olanlar, reçeteli ilaçlar için on Euro\'ya kadar katkı payı ödemek zorundadır. \n\n Katkı payı miktarı, ilacınızın fiyatına bağlıdır. Maliyeti 5 €\'dan az olan ilaçlar için kendiniz ödeme yapmanız gerekir.\n Daha pahalı ilaçlar için fiyatın yüzde onunu, ancak en az 5 € ve maksimum 10 € ödemeniz gerekir. \n\n 18 yaşın altındaki çocuklar ve gençler genellikle katkı payından muaftır. \n\n Yıllık ilaç masraflarınız mali limitinizi aşarsa, katkı payından muaf olabilirsiniz. Bu konuda sağlık sigortanız ile konuşun. + Bu ilacın katkı payından muafsınız. Sağlık sigortanız ilacın maliyetini karşılayacaktır. + Bu reçete ne kadar süreyle geçerlidir? + Bu süre zarfında reçetenizi herhangi bir eczanede ek bir ödeme ile kullanabilirsiniz. + Bu süre zarfında reçeteyi eczaneden almaya devam edebilirsiniz, ancak satın alma fiyatını kendiniz ödemeniz gerekir. Alternatif olarak, muayenehanenizden reçeteyi yeniden vermesini isteyebilirsiniz. + Muadil mümkün + Sağlık sigortanızın yasal zorunlulukları nedeniyle, aynı etken maddeye sahip bir alternatif sunulabilir. \n\n İlaçlar farklı görünebilir ve farklı adlandırılabilir, farklı fiyatlara ve üreticilere sahip olabilir, ancak yine de aynı etken maddeyi içerir. Aktif bileşenin kendisi ve dozajı, ilaçların vücuttaki etkisi için özellikle önemlidir. Eczanedeki hastalar, ilaçların karşılaştırılabilir olması koşuluyla, genellikle doktorun reçetede yazdığından farklı bir ilaç alırlar. Değişimin terapötik ve ekonomik nedenleri olabilir. + Taranmış reçete + Güvenlik nedeniyle, kağıt çıktıdan alınan reçeteler herhangi bir kişisel veya tıbbi veri içermemelidir. \n\n Reçetede yer alan tüm bilgileri görüntülemek için bu uygulamada sağlık kartı veya sigorta uygulamasıyla oturum açın. + Tarif yanlış + Bu reçete yanlış yazılmıştır. + Taranmış reçete + acil servis ücreti + Yazılı talimatlara göre dozaj + telefon + alan + E-posta + Mesafeye göre sıralama mümkün değil. + Tamam + Geçerli PIN\'i girin + Yanlış PIN girildi + Sağlık kartınızın mevcut PIN\'i + Kart bloke edildi + Ayarlar > Kartın engellemesini kaldır\'da kartınızın engellemesini kaldırın. + Güvenlik nedeniyle lütfen mevcut PIN\'inizi girin. + PIN\'i unuttum + Yanlış tarif + uyuşturucu + Tarifinizi oluştururken bir şeyler ters gitmiş gibi görünüyor. Hata bildir? + Bildiri + Oturum açılmamış + ile kayıtlı + Sağlık kartı + Biyometri + Oturum açılmamış + Fikrinizle ilgileniyoruz. Lütfen anketimizi yanıtlamak için beş dakikanızı ayırın. Şimdiden teşekkür ederim. + uyarı notu + Eczane favorilere eklendi + Eczane Favorilerden Kaldırıldı + eczanelerim + Şifre gücü çok iyi + Yazma işlemi başarısız + PIN kaydedilemedi + Bildiri + PIN ata + Erişim kuralı ihlal edildi + Harita dizinine erişim izniniz yok. + Kendi pininizi atayın + Kart, sağlık sigortanızın verdiği bir PIN (ulaşım PIN) ile güvence altına alınmıştır, lütfen kendi PIN kodunuzu atayın. + Şifre bulunamadı + Kartınızda kayıtlı şifre yok. + Çıkış yaptınız + Tariflerinizi güncellemek için tekrar oturum açın. + aktif madde numarası + güç ve birlik diff --git a/android/src/main/res/values-uk/strings.xml b/android/src/main/res/values-uk/strings.xml new file mode 100644 index 00000000..229d84b4 --- /dev/null +++ b/android/src/main/res/values-uk/strings.xml @@ -0,0 +1,789 @@ + + + E-Rezept + Ok + Скасувати + Назад + о + Цифрово. Швидко. Надійно. + Додати рецепти + Ви отримали видрук рецепта? Додайте рецепти до застосунку, відсканувавши відповідний код рецепта. + Зрозуміло + Ідентифікатор завдання + Код доступу + Умови використання + Політика конфіденційності + Рецепти + Відмовлено в доступі до камери + Щоб скористатися сканером, потрібно дозволити застосунку доступ до камери в системних налаштуваннях. + Сфокусуйте камеру на коді рецепта + Це недійсний код рецепта + Цей код рецепта уже відскановано + + Розпізнано один рецепт + Розпізнано %s рецепти + Розпізнано %s рецептів + + + Скасувати + Світло камери + Скасувати сканування кодів рецептів? + Скасувати сканування + Продовжити + Почнімо + Вам потрібно: + Введіть номер доступу + Ввести PIN-код + Спробуйте ще раз + Помилка з’єднання з сервером + Введено неправильний PIN-код. + + У вас ще одна спроба, перш ніж буде заблоковано картку + У вас ще %s спроби, перш ніж буде заблоковано картку + У вас ще %s спроб, перш ніж буде заблоковано картку + + + Введено неправильний CAN + Ви знайдете номер доступу у верхньому правому куті своєї картки здоров\'я. + Скасувати + Пошук картки... + Тримайте картку здоров’я на задній панелі пристрою. + Усе ще триває пошук... + Повільно переміщуйте картку на задній панелі пристрою. + Порада + Корпуси пристрою можуть ускладнити з\'єднання через NFC. + Картку розпізнано + Намагайтеся не рухати карту здоров’я. + Картку здоров’я знайдено. Не рухайтеся. + Підключення скасовано + Ще раз прикладіть картку здоров’я до задньої панелі пристрою. + Версія: %s + Хеш збірки: %s + Меню налагодження + Код рецепта + Відскануйте цей код рецепта у своїй аптеці. + Цей збірний код об’єднує %s рецептів + Погасити в аптеці + Ви стоїте в аптеці й хочете отримати лікарства по рецепту. + Замовити або зарезервувати + Відправте свій рецепт в аптеку і вкажіть, як ви хочете отримати ліки. + Поділіться місцем розташування та знайдіть аптеки у вашому регіоні + Поділитися місцем розташування + Відчинено до %s + Відчинено без перерв + Вихідні дані + Видавець + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Керівний директор: д-р. мед. н Маркус Лейк Дікен (Markus Leyck Dieken)\n Реєстраційний суд: окружний суд Берлін-Шарлоттенбург\n Номер торгового реєстру: HRB 96351\nІН платника ПДВ: DE241843684 + Відповідальний за контент + Доктор мед. н. Маркус Лейк Дікен + Контакт + Указівка + Ми намагаємося використовувати гендерно нейтральну мову. Якщо ви помітили помилки, ми будемо раді вашим повідомленням електронною поштою. + Сучасна німецька платформа для цифрової медицини + Написати електронне повідомлення + Відкрити вебсайт + Вітаємо! + Розпочати реєстрацію + Розблокувати + Вхід + Скасувати + Безпека + Правові питання + Вихідні дані + Захист даних + Умови використання + Деталі + Позначити як погашено + Позначити як не погашено + Лікарська форма + стандартний розмір + Застрахована особа + Прізвище + Адреса + Дата народження + Медична страхова компанія / Носій витрат + Статус + Страховий номер + Особа, яка призначає рецепт + Прізвище + Профільний лікар + Номер лікаря (LANR) + Установа + Прізвище + Адреса + Номер виробничого майданчика + Номер телефону + Ел. пошта + Нещасний випадок на виробництві + День нещасного випадку + Номер місця нещасного випадку або номер роботодавця + Видалити цей рецепт безповоротно? + Видалити + Скасувати + Замінники дозволені. Відповідно до законодавчих вимог вашої лікарняної каси вам можуть надати альтернативний медикамент. + Зарезервувати з зобов’язанням придбання + Зробити запит кур\'єрської служби + Доставити поштою + Зауважте, що за виписані ліки також може нараховуватися доплата. + Графік роботи: + Вебсайт + Бажаєте викупити наступні рецепти в %s, що матиме обов’язковий характер? + Ще тільки сьогодні рецепт можна погасити, якщо ви оплачуєте самостійно. + Вхід + Активувати NFC + Активуйте функцію NFC на своєму пристрої, щоб увійти за допомогою своєї картки здоров’я. + Активувати + Відкоректувати + Показати як окремі коди + Показати як збірний код + %s з %s + Рецепти погашено? + Позначити рецепти як погашені? + Не погашено + Погашено + Відчиняється о %s + +49 800 277 377 7 + Технічна гаряча лінія + Відкрити сканер для рецептів + Налаштування + Указівка + Ця зміна набуде чинності лише після перезапуску застосунку. + Ok + Придушити скріншоти + Запобігає відображенню заставки під час переходу з одного застосунку до іншого + Чи дозволяєте ви застосунку E-Rezept анонімно аналізувати поведінку користувача? + Технічна інформація + Безпека ваших даних рецепта + Майте на увазі, що особи, з якими ви, можливо, спільно користуєтеся цим пристроєм і чиї біометричні функції можуть зберігатися на цьому пристрої, або які мають PIN-код пристрою, графічний ключ або пароль, також матимуть доступ до ваших рецептів. + Помилка відправлення + Не налаштований жодний поштовий клієнт + Немає результатів + За цим словом пошуку не знайдено жодних результатів. + Ліцензії з відкритим кодом + Контакт + Зателефонувати на технічну гарячу лінію + Взяти участь в опитуванні + +49 800 277 377 7 + Детальніше + Я хочу допомогти покращити цей застосунок + Це включає інформацію про апаратне та програмне забезпечення на вашому телефоні, налаштування застосунку E-Rezept та обсяг використання, однак у жодному разі не дані про вас особисто чи ваше здоров’я. + Ці дані надаються тільки gematik GmbH компанією, яка обробляє дані, та видаляються не пізніше ніж через 180 днів. Аналіз можна деактивувати в меню застосунку в будь-який час. + Ці дані дозволяють нам зрозуміти, які функції часто використовуються, і покращити їх. Крім того, ми можемо оцінити, як довго має підтримуватися старіша технологія і коли ми можемо, наприклад, зробити нову версію операційної системи обов’язковою, щоб це не зачепило (занадто багато) користувачів. + Покращити застосунок + Анонімний аналіз залишається деактивованим + %s Дякуємо за Вашу підтримку! + Указівка + Перед тим, як погашені рецепти будуть переміщені в архів, може виникнути затримка. + Ok + Вхід + Щоб завантажити рецепти, ідентифікуйте себе. + Дата погашення: %s + Примітка для аптек: ми отримуємо контактні дані та інформацію про аптеки від mein-apothekenportal.de Німецької аптечної асоціації Deutsche Apothekenverband e.V. Ви виявили помилку чи хотіли б виправити дані? + Детальніше + Аптеки + На жаль, спроба невдала \uD83D\uDE15 + Повторіть спробуй + Введіть пароль + Далі + Довідки з керування + Масштабувати + Дозволяє збільшувати масштабування застосунку зведенням або розведенням пальців (зведення для масштабування). + Указівка + Пароль: + Захистіть свої дані паролем на свій вибір. + Пароль + Зберегти + Відобразити пароль + Повторно введіть пароль + Рекомендації: %s + Написати електронне повідомлення + Під час надсилання своїх повідомлень буде передаватися вказана нижче інформація про обладнання та операційну систему, що використовується: + Погасити якомога швидше + Наразі ця аптека ще не може приймати електронні рецепти. + Наразі відчинено + Кур\'єрська служба + Розсилання + Фільтр + Фільтрувати + Жодна локація не доступна + Зрозуміло + Паролі не збігаються + + Залишається ще один день, щоб погасити рецепт, якщо ви оплачуєте самостійно. + Залишається ще %s дні, щоб погасити рецепт, якщо ви оплачуєте самостійно. + Залишається ще %s днів, щоб погасити рецепт, якщо ви оплачуєте самостійно. + + + + Діє ще один день + Діє ще %s дні + Діє ще %s днів + + + Відкрити сканер + Ми обробляємо інформацію про ваш пристрій!\nЦей застосунок використовує набір ML Kit від Google для читання коду рецепта. Якщо ви виберете «Прийняти», ви погоджуєтеся з тим, що Google може час від часу отримувати доступ до інформації пристрою та обробляти її з метою аналізу використання, діагностики та конфігурації ML Kit. Ви маєте право в будь-який час відкликати свою згоду, не впливаючи на законність виконаної обробки. Однак відхилення призведе до того, що ви не зможете використовувати сканер коду рецепта. + Погоджуюся + Скасувати + Помилка 20 10 76631 + Сертифікат вашої картка здоров\'я недійсний. Можливо, термін дії вашої картки закінчився? Зв’яжіться зі своєю медичною страховою компанією. + Невдалі спроби входу + + Виявлено одну вдалу спробу входу в систему. + Виявлено %s вдалі спроби входу в систему. + Виявлено %s вдалих спроб входу в систему. + + + Вибрати найкращий захист пристрою + Це може бути відбиток пальця, графічний ключ або щось подібне. + Токени + Токен доступу + Токени SSO + Не доступний жоден токен доступу + не доступний жоден токен SSO + скопійовано в буфер обміну + Натисніть, щоб скопіювати токен у буфер обміну + дійсний ще тільки сьогодні + Дозволити + Підключення до сервера відсутнє + Спробуйте ще раз через кілька хвилин. + Завантажити ще раз + Показати токени + Як бажаєте захистити цей застосунок? + Указівка + Для цього пристрою не було налаштовано захист пристрою + Ми рекомендуємо вам додатково захистити свої медичні дані за допомогою захисту пристрою, наприклад паролем або біометричними заходами безпеки. + Більше не показувати цю вказівку в майбутньому. + Помилка підключення. Не вдалося встановити з’єднання з мережею. + Помилка встановлення зв’язок із сервером: код статусу %s + Помилка встановлення зв’язок із сервером: помилка VAU + Попередження + Цьому пристрою, мабуть, не можна повністю довіряти. + З міркувань безпеки цей застосунок не слід використовувати на рутованих пристроях. + Я беру до уваги підвищений ризик, та все ж хочу продовжувати. + Чому пристрої з root-доступом становлять потенційну загрозу безпеці? + Детальніше + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + Ім\'я профілю + Введіть ім\'я для нового профілю. + Ім\'я профілю + Профілі + Додати профіль + Картка здоров\'я + Зверніться до медичної страхової компанії + Щоб увійти в цей застосунок, вам потрібна картка здоров\'я з підтримкою NFC та відповідний PIN-код. + Її ви отримаєте безкоштовно від своєї медичної страхової компанії. Для цього ви повинні ідентифікувати себе за допомогою офіційного документа, що посвідчує особу. + Так можна розпізнати картку здоров\'я з підтримкою NFC + Вибрати медичну страхову компанію + Вибір відсутній + Бажаєте подати заявку? + Зв\'язатися через цей застосунок не можливо + Використовуйте звичайні канали, щоб зв\'язатися зі своєю страховою компанією. + Картка здоров’я та PIN + Тільки PIN + Зв\'яжіться зі своєю медичною страховою компанією + Вхід у застосунок E-Rezept + Поле прізвища не може бути порожнім. + Профіль із введеним іменем уже існує. + Профіль + Вибрано %s + Колір фону + Весняний сірий + Росичка + Це! Рожевого! Кольору! + Дерево + Синій місяць вересень + Не в системі + З\'єднано + Дата останнього підключення: %s + Видалити профіль? + Таким чином буде видалено всі дані профілю на цьому пристрої. Ваші рецепти в мережі охорони здоров’я залишаться неушкодженими. + Видалити + Скасувати + Видалити профіль + Ви хочете видалити останній профіль. + Для застосунку потрібен принаймні один профіль. Введіть ім\'я для нового профілю. + Помилка 20 10 76831 + Не вдалося отримати доступ до каталогу карток здоров\'я. Спробуйте ще раз. + На Національному порталі охорони здоров’я можна знайти фахово перевірену інформацію про хвороби, коди МКХ та питання щодо профілактики та догляду. + Відкрити gesund.bund.de + Ми змінили положення Політики конфіденційності + Застосунок E-Rezept було вдосконалено. Внаслідок цього виникла необхідність оновлення нашої політики конфіденційності. + Відкрити положення Політики конфіденційності + З %s відбулися зміни: + Що буде, коли ви відкриєте застосунок? + Що станеться, якщо я використаю функцію камери / зчитаю рецепти за допомогою камери? + Вибрати профіль + Редагувати профілі + Нових рецептів немає + + Один рецепт актуалізовано + %s рецепти актуалізовано + %s рецептів актуалізовано + + + Можна погасити + Погашається + Погашено + Невідомо + Деталі + Відобразити протоколи доступу + Тут ви можете побачити, хто мав доступ до ваших рецептів + Мова про ключ доступу до сервісу рецептів + Протоколи доступу + Протоколи доступу відсутні + Ви отримаєте протоколи доступу, якщо ви увійшли в службу рецептів. + Протоколів доступу ще немає. + Останнє оновлення: %s + Наразі рецепт обробляється, і його неможливо видалити. + Прийняти + Мабуть, спроба невдала. + Ми усвідомлюємо, що підключення до картки здоров’я має свої \"підводні камені\". Тому в майбутньому вхід також стане можливим через уже автентифікований застосунок лікарняної каси.\n\nКрім того, ми працюємо над тим, щоб рецепти можна було погашати в цифровій формі навіть без входу в систему.\n\nЧи помітили ви під час цього процесу щось, про що хотіли би повідомити нам? Напишіть нам, ми будемо раді навіть дуже критичним відгукам. + Поради щодо підключення + Покращте силу з\'єднання + За можливості видаліть захисну оболонку + Якщо пристрій вібрує, а потім розриває з’єднання, шукайте оптимальне положення в невеликому радіусі. + Переміщуйте пристрій по карті дуже повільно. + Помістіть пристрій безпосередньо на картку + Для цього покладіть картку здоров’я на рівну поверхню (наприклад, стіл). + Покращте силу з\'єднання + Слідкуйте за розміщенням датчика NFC + Дізнайтеся, де у вашому пристрої міститься датчик NFC (тут, наприклад, огляд пристроїв %s) + У деяких випадках положення датчика NFC може відрізнятися в межах серії моделі (тут, наприклад, дані для %s). + Наступна порада + Далі + Закрити + Спробувати + Пишіть нам + Ліцензія, пошук аптеки + Погасити + Відсканований рецепт + Дата сканування: %s + Дата позначення як погашено: %s + Бажаєте продовжити? + Замовити + Незабаром буде в наявності + Забронюйте зараз для самовивозу, доставлення кур’єрською службою або поштою + Зберегти для пізніших замовлень + Зберегти рецепти на пристрої + + Далі з одним рецептом + Далі з %s рецептами + Далі з %s рецептами + + + Помилка підключення картки здоров’я + Поточний профіль уже підключений до іншої картки здоров\'я (номер медичного страхування: %s). + Ваша картка здоров\'я вже прив\'язана до іншого профілю. Перейдіть до профілю %s. + Моє замовлення + Зарезервувати зараз + Замовити зараз + Зберегти + Контактні дані й адреса + Контакт + Номер телефону + Введіть номер телефону для контакту. + Адреса ел. пошти (опція) + Адреса доставлення + ПІБ + Введіть ім\'я та прізвище для контакту. + Вулиця та номер будинку + Введіть вулицю та номер будинку для контакту. + Додаток до адреси (опція) + Індекс і нас. пункт + Введіть поштовий індекс і населений пункт для контакту. + Інструкція з доставлення (опція) + Таким чином, ваш рецепт буде надіслано в цю аптеку. Після цього ви не зможете погасити його в жодній іншій аптеці. + Контактні дані й адреса доставлення + Рецепти + Нам потрібні ваші контактні дані для консультації в аптеці та для інформування вас про поточний стан вашого замовлення. + Надати контактні дані + Потрібні подальші контактні дані + Замовлення успішно переслано + Незабаром ваша аптека зв’яжеться з вами. + Закрити + Скинути зміни? + Скинути + Для пошуку в довіднику аптек використовуються геолокацію, визначені за допомогою OpenStreetMap. Ми дякуємо проєкту за цю допомогу. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + Користування і захист даних + Далі + Ви отримали свій PIN-код у листі від вашої медичної страхової компанії. + PIN-код не отримано + PIN + Перевірте підключення до Інтернету та налаштування часу/дати на вашому пристрої. + Для автентифікації натисніть \"Розблокувати\". + Заблоковано? Перевірте свої біометричні облікові дані на цьому пристрої. + Забули пароль? Видаліть застосунок, а потім повторно встановіть його. Чому це так, ви можете дізнатися в %s + Довідка + розмір упаковки та одиниця + Активна речовина + Кількість активної речовини + Позначення партії + Використовувати до + Категорія + Вакцина + Прийняти + Скасувати + Указівка + Допоможете нам покращити цей застосунок? + Вибрати власний пароль + Пароль повинен містити не менше восьми символів + Надійність пароля не достатня + Надійність пароля достатня + Пароль видимий. + Пароль не видимий. + Біометрія + Пароль + Зачекайте на відповідь + Кожних рецептів + У вас наразі немає рецептів, які можна було б погасити + Оновити + Автоматичний вихід з системи + З міркувань безпеки з’єднання з сервером рецептів припиняється через 12 годин. Підключіться знову, щоб отримати поточні рецепти. + Підключити + Ви отримали видрук? + Додайте рецепти до свого списку, натиснувши кнопку сканування у верхньому правому куті. + Зісканувати видрук + Щоб автоматично отримувати рецепти, ви повинні увійти в систему. + Вхід + Непогашених рецептів немає + Тут відображаються ваші погашені рецепти. З міркувань захисту персональних даних ваші рецепти будуть видалені з сервера рецептів через 100 днів. + Непогашених рецептів немає + Тут відображаються ваші погашені рецепти. Додайте рецепти за допомогою сканування, щоб почати погашення рецепта. + Керування пристроями + Підключені пристрої + Зареєстровано з %s (цей пристрій) + Зареєстровано з %s + Актуальний + Архів + Погасити повторно? + Примітка. Аптека, яка першою приймає рецепт, блокує його обробку в іншій аптеці. + Скасувати + Ok + + Ви уже відправили рецепт %s в одну аптеку. Погасити повторно? + Ви уже відправили декілька рецептів в одну аптеку. Погасити повторно? + Ви уже відправили декілька рецептів в одну аптеку. Погасити повторно? + Ви уже відправили декілька рецептів в одну аптеку. Погасити повторно? + + З міркувань безпеки підключення до сервера рецептів припиняється через 12 годин. Для повторного підключення вам знадобиться медична картка та PIN-код для кожного процесу підключення. + PIN + Ввести PIN-код (картки здоров’я) + Далі + Аутентифікація + Підключені пристрої + Видалити пристрій? + Скасувати + Видалити + Видалити цей пристрій? + Бажаєте видалити %s? + Якщо ви видалите %s, то підключення до сервера рецептів буде остаточно розірвано не пізніше ніж через 12 годин. + Триває завантаження пристроїв… + Пристрої відсутні + До цієї картки здоров’я не підключений жоден пристрій. + Спробуйте ще раз + Ого :-( + Не вдалося завантажити список пристроїв. + Немає зв\'язку + Підключення до Інтернету відсутнє. + Ліки та перев\'язувальні засоби + Наркотичний засіб + Видача ліків за рецептом згідно з § 4 Постанови про рецепти на ліки (AMVV) + Вам потрібна допомога? + Ми зібрали для вас поради щодо розв\'язання найпоширеніших проблем. + Запустити поради щодо підключення + Дата сканування: %s + Відсканований рецепт + Розблокувати + Картка заблокована + PIN-код тричі введено неправильно. Таким чином, ваша картка заблокована з міркувань безпеки. + Розблокувати картку + Ввести PUK-код + Разом з PIN-кодом ви отримали від своєї медичної страхової компанії 8-значний PUK-код. + Вибрати новий PIN-код + Ви можете самостійно вибрати свій новий персональний ідентифікаційний номер (PIN-код) (від 6 до 8 цифр). + Запам\'ятали PIN-код? + Занотуйте собі свій PIN-код та зберігайте його в надійному місці. + Скасувати + Введено неправильний PUK-код. + Ok + Розблокування не можливе + За допомогою цього PUK-коду ви досягли максимальної кількості розблокувань картки або ще раз ввели його неправильно. Зв’яжіться зі своєю страховою компанією. + Один PUK-код можна використовувати для 10 розблокувань. + Картка розблокована + Вам потрібно: + Ваша картка здоров\'я + PUK-код вашої картки здоров\'я + Далі + Картка здоров\'я + Замовити нову картку + Вхід + Отримуйте рецепти онлайн і переадресовуйте їх в аптеку. + Картка здоров’я з підтримкою NFC + PIN-код картки здоров’я + У вас ще немає картки здоров’я з підтримкою NFC та PIN-коду до неї? + Замовити зараз + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + Або: увійдіть за допомогою %s. + Застосунок вашої медичної страхової компанії + "Ваш номер доступу (Card Access Number, скорочено CAN) ви знайдете у верхньому правому куті своєї картки здоров\'я." + Моя картка не має номера доступу + + У вас ще одна спроба, перш ніж буде заблоковано картку + У вас ще %s спроби, перш ніж буде заблоковано картку + У вас ще %s спроб, перш ніж буде заблоковано картку + + + Прикласти картку здоров\'я до задньої панелі телефона + Цей процес може тривати до 30 секунд. + Розмістіть картку %s на задній панелі телефона. + у верхній частині справа + у верхній частині по центру + у верхній частині зліва + у середній частині справа + по центру + у середній частині зліва + у нижній частині справа + у нижній частині по центру + у нижній частині зліва + Довідка + Відправлено %s хв тому + Дата відправлення: %s + Відправлено щойно + Час відправлення: %s + Більше не дійсний + Увійдіть за допомогою застосунку + Вибрати страховку + Не знайшли те, що шукали? Цей список постійно розширюється. Реєстрацію за допомогою картки здоров\'я вже підтримує кожна лікарняна каса. + Відгук із застосунку E-Rezept + Ми з нетерпінням чекаємо на ваші відгуки. Використовуйте місце нижче та формулюйте свої думки якомога точніше: + PUK + Закрити + Шкода... + На жаль, ваш пристрій не відповідає мінімальним вимогам для входу в застосунок E-Rezept. Для безпечної автентифікації за допомогою картки здоров’я потрібні щонайменше версія Android 7 і чіп NFC. + Детальніше + Зберегти дані доступу? + Зберегти + Не зберігати + Указівка + З міркувань безпеки підключення до сервера рецептів припиняється через 12 годин. Для повторного підключення вам знадобиться медична картка та PIN-код для кожного процесу підключення. + На цьому пристрої не налаштовано біометричний захист. + Неможливо зберегти дані доступу. Заздалегідь налаштуйте біометричний захист (наприклад, відбиток пальця) на своєму пристрої. + Скасувати + Налаштування + Указівка + Прийняти + Безпека ваших даних рецепта + \"Цей застосунок використовує найбезпечніший біометричний датчик, наданий вашим пристроєм, щоб зберігати ваші облікові дані в безпечній області пам’яті пристрою.\" + Біометрична безпека ваших даних доступу дозволяє вам відкривати цю програму в майбутньому, не вводячи свій PIN-код або картку здоров’я, а також переглядати, викликати, використовувати або видаляти рецепти. + Майте на увазі, що особи, з якими ви, можливо, спільно користуєтеся цим пристроєм і чиї біометричні функції можуть зберігатися на цьому пристрої, або які мають PIN-код пристрою, графічний ключ або пароль, також матимуть доступ до ваших рецептів. + На жаль, спроба невдала. + Аутентифікація за допомогою застосунку лікарняної каси була невдалою. + Термін дії минув %s + Рецепт уже видалено з сервера + Виправте введені дані або скасуйте зміни + Відкоректувати + Дані застрахованої особи + Прізвище + Страхування + Страховий номер + Номер доступу (CAN) + Вхід + Вийти з системи + Щоб автоматично отримувати рецепти, ви повинні увійти в систему. + Зберегти + Зміна + Редагувати зображення профілю + Далі + Сервер не відповідає + Повторіть спробу пізніше. + Спробуйте ще раз + Шукайте страховку + Зараз підключитися до сервера рецептів? + Успішно ввійшли + зв\'язок втрачено + Підключитися до сервера рецептів? + Ніяких жетонів + Ви отримаєте жетон, коли зареєструєтесь у службі рецептів.\n + замовлення + Виберіть потрібний PIN-код + Розблокувати картку + Виберіть PIN-код + Повторіть PIN-код + Записи відрізняються один від одного. + Без замовлень + У вас ще немає замовлень. + Прямо зараз + О %s годині + Кошик для покупок готовий + Рецепт додано у ваш кошик. Щоб оформити замовлення, перейдіть на сайт аптеки. + Відкрити кошик + Покажіть цей код отримання в аптеці. + Отримати код для самовивозу + Неможливо відобразити повідомлення + Будь ласка, зверніться до вашої аптеки ( %s ). + Показати посилання на кошик + Показати код для самовивозу + Показати повідомлення + %s о годині %s + Рецепт надіслано %s . + Огляд замовлення + новий + курс + замовлення + Безкоштовно для абонента. Графік роботи: Пн – Пт 8:00 – 20:00, крім національних свят + Аптека + Виберіть потрібний PIN-код + Бажаний PIN-код збережено + Неможливо зберегти потрібний PIN-код + E-Rezept + Наразі відкрито і біля мене + Фільтрувати за… + почати пошук + Скасувати + Буде викуплено для вас + пряме доручення + Погасити + номер телефону (необов\'язково) + Пошук за прізвищем або адресою + Немає дійсної інформації про аптеку + Актуальної інформації про цю аптеку не знайдено. Запис про цю аптеку буде видалено. + Ok + Каталог аптек недоступний + Наразі актуальну інформацію про цю аптеку отримати неможливо. Перевірте підключення до Інтернету. + Скасувати + Спробуйте ще раз + Зберегти довкілля + Вхід неможливий + Здається, ваші біометричні облікові дані для входу змінилися. Будь ласка, зареєструйтеся знову за допомогою вашої медичної картки. + Скасувати + Вхід + профіль 1 + Поруч зі мною + У них немає рецептів, які можна викупити + Викупити пізніше + Можна отримати від %s + %s / %s + вдосконалення продукту + Анонімний аналіз + Допоможіть нам зробити цю програму кращою. Усі дані користувача збираються анонімно та використовуються лише для покращення взаємодії з користувачем. + безпека пристрою + персональні налаштування + Довідки з керування + вдосконалення продукту + Доданий рецепт + Рецепт вже є + Під час імпортування сталася помилка + Видалити + Відсканований рецепт + Можливий замінник + Надіслано %s о %s + Використано %s о %s + Забув PIN-код + + %s рецепт + + + %s Рецепти + + Я прочитав і приймаю політику конфіденційності та умови використання. + Політика конфіденційності + Умови використання + Ми хотіли б: + Покращте зручність використання. + Виявляти помилки та збої. + Усі дані, звичайно, збираються анонімно. + В налаштуваннях системи ви можете змінити це рішення у будь-який час. + Продовжити + Прийняти + Ця програма використовує найбезпечніший метод, доступний вашим пристроєм. + Зберегти + Виберіть + Медикамент + Торгова назва + Так + ні + дозування + дата випуску + noctu + Цей рецепт буде використано для вас як частина лікування. + Дані відсутні + Немає плати за екстрену службу + додаткова оплата + Медикамент + Накладні на доставку + Відповідає вимогам BVG + альтернативна підготовка + назва рецепта + Упаковка + інструкція по виготовленню + опис + дається + видано: + Активна речовина + прописаний + Отримати + Що таке пряме доручення? + У разі прямого направлення, рецепт від практики або лікарні викуповується безпосередньо в аптеці. Застраховані особи не повинні вживати жодних дій і не можуть втручатися в процес викупу. \n\n Прямі напрямки перераховані в додатку для електронних рецептів, щоб зробити ваше лікування більш прозорим для вас. + Немає плати за екстрену службу + Поспішати тут на порядку денному. Цей рецепт також можна отримати вночі в аптеці без додаткової оплати екстреної допомоги. + Препарати, що підлягають співоплаті + Звільнено від співоплати + Ті, хто має державне медичне страхування, повинні сплачувати доплату в розмірі до десяти євро за рецептурні ліки. \n\n Сума співоплати залежить від вартості ваших ліків. За ліки, які коштують менше 5 євро, ви повинні платити самі.\n За ліки, які коштують дорожче, потрібно заплатити десять відсотків від ціни, але не менше 5 євро, а максимум 10 євро. \n\n Діти та молодь віком до 18 років, як правило, звільнені від співоплати. \n\n Якщо ваші річні витрати на ліки перевищують ваш фінансовий ліміт, ви можете бути звільнені від співоплати. Поговоріть про це зі своїм медичним страхувальником. + Ви звільнені від спільної оплати цього препарату. Ваше медичне страхування покриває вартість ліків. + Скільки дійсний цей рецепт? + У цей період ви можете отримати рецепт в будь-якій аптеці з доплатою. + Протягом цього періоду ви все ще можете придбати рецепт в аптеці, але ви повинні заплатити вартість покупки самостійно. Крім того, ви можете попросити свою практику виписати рецепт повторно. + Можливий замінник + Відповідно до юридичних вимог вашої страхової компанії, вам можуть надати альтернативу з тим самим діючим інгредієнтом. \n\n Ліки можуть виглядати і називатися по-різному, мати різні ціни та виробників, але містити однакову діючу речовину. Сам активний інгредієнт і дозування особливо важливі для впливу ліків на організм. Пацієнти в аптеці часто отримують інший препарат, ніж той, який виписав лікар за рецептом – за умови, що препарати порівнювані. Для зміни можуть бути терапевтичні та економічні причини. + Відсканований рецепт + З міркувань безпеки рецепти, імпортовані з паперової роздруківки, не повинні містити жодних особистих чи медичних даних. \n\n Увійдіть у цю програму за допомогою медичної картки або програми страхування, щоб переглянути всю інформацію, що міститься в рецепті. + Рецепт невірний + Цей рецепт був виписаний неправильно. + Відсканований рецепт + плата за екстрену службу + Дозування відповідно до письмових інструкцій + Телефон + сайт + Ел. пошта + Сортування за відстанню неможливе. + Ok + Введіть поточний PIN-код + Введено неправильний PIN-код + Поточний PIN-код вашої медичної картки + Картка заблокована + Розблокуйте картку в Налаштуваннях > Розблокувати картку. + З міркувань безпеки введіть поточний PIN-код. + Забув PIN-код + Невірний рецепт + Медикамент + Здається, під час створення вашого рецепта щось пішло не так. Повідомити про помилку? + Повідомити про порушення + Не в системі + Зареєстрований с + Картка здоров\'я + Біометрія + Не в системі + Нам цікава ваша думка. Приділіть п’ять хвилин, щоб відповісти на наше опитування. Спасибі заздалегідь. + попереджувальне повідомлення + Аптека додана в обране + Аптеку видалено з вибраного + Мої аптеки + Надійність пароля дуже добра + Операція запису не вдалася + Не вдалося зберегти PIN-код + Повідомити про порушення + Призначити PIN-код + Правило доступу порушено + Ви не маєте дозволу на доступ до каталогу карт. + Призначте власний PIN-код + Картка захищена PIN-кодом вашої страхової компанії (транспортний PIN-код), будь ласка, призначте свій власний PIN-код. + Пароль не знайдено + На вашій картці не збережено пароль. + Ви вийшли з системи + Увійдіть знову, щоб оновити свої рецепти. + номер активного інгредієнта + потужність і єдність + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 56342e73..94265066 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,703 +1,374 @@ - E-Rezept - Okay - Abbrechen - Zurück - um - %1$s Uhr - Zuletzt aktualisiert am %1$s - Aktualisierung fehlgeschlagen. Bitte aktualisieren Sie Ihre Rezepte erneut. - Digital. Schnell. Sicher. - Willkommen in der E-Rezept-App - Hier können Sie elektronische Rezepte in einer Apotheke Ihrer Wahl einlösen, direkt vor Ort oder online. - Mehr Funktionen mit Ihrer Gesundheitskarte - Aktualisieren Sie automatisch Ihre neuen Rezepte - Informationen zur Einnahme und Dosierungen Ihrer Medikamente - Empfangen Sie Mitteilungen Ihrer Apotheke zu Ihrer Bestellung - Nutzungsbedingungen & Datenschutzerklärung - Um die App nutzen zu können, stimmen Sie bitte den Nutzungsbedingungen zu und bestätigen Sie die Kenntnisnahme der Datenschutzbedingungen. Es werden nur Daten erfasst, die für das Funktionieren der Dienste unerlässlich sind. - Ich habe die %s gelesen und akzeptiere sie. - Nutzungsbedingungen - Datenschutzerklärung - Bestätigen - Weiter - Bestätigen - Rezepte hinzufügen - Sie haben einen Rezept-Ausdruck erhalten? Rezepte fügen Sie der App hinzu, indem Sie den jeweiligen Rezeptcode abscannen. - Verstanden - Task-ID - Access-Code - Kopiert - Nutzungsbedingungen - Datenschutzerklärung - Nutzungsbedingungen akzeptieren - Datenschutzerklärung akzeptieren - Rezepte - Rezepte - Mitteilungen - Einlösen - - Noch %s Tag gültig - Noch %s Tage gültig - - z. B. Hautärztin - %s %s aus %s %s erkannt. Weitere Codes scannen? - - Rezept - Rezepten - - - Rezept - Rezepte - - - %s Rezept hinzufügen - %s Rezepte hinzufügen - - Zugriff auf Kamera verweigert - Um den Scanner verwenden zu können, müssen Sie der App in den Systemeinstellungen den Zugriff auf Ihre Kamera gestatten. - Fokussieren Sie mit der Kamera auf einen Rezeptcode - Hierbei handelt es sich um keinen gültigen Rezeptcode - Dieser Rezeptcode wurde bereits abgescannt - - %s Rezept erkannt - %s Rezepte erkannt - - Abbrechen - Kameralicht - Scannen von Rezeptcodes abbrechen? - Scannen abbrechen - Fortfahren - Karte hinzufügen - Los geht’s - Jetzt alle Funktionen nutzen - Um alle Funktionen der App nutzen zu können, melden Sie sich mit Ihrer Gesundheitskarte an. Diese Karte sowie die benötigen Zugangsdaten erhalten Sie von Ihrer Krankenversicherung. - Was Sie benötigen: - Eine Gesundheitskarte mit Zugangsnummer (CAN) - Die PIN zur Gesundheitskarte - Mit Karte anmelden - Keine Verbindung möglich - Leider erfüllt Ihr Smartphone die Mindestanforderungen für die Nutzung der E-Rezept-App mit Ihrer elektronischen Gesundheitskarte nicht. - Warum gibt es Mindestanforderungen für die Verbindung von App und elektronischer Gesundheitskarte? - Ihre Kartenzugangsnummer (Card Access Number, kurz: CAN) hat 6 Stellen. Sie finden die CAN in der rechten oberen Ecke der Vorderseite Ihrer Gesundheitskarte. Steht hier keine sechsstellige Zugangsnummer, benötigen Sie eine neue Gesundheitskarte von Ihrer Krankenversicherung. - Zugangsnummer eingeben - Sie können beliebige Ziffern angeben. - Ihre PIN kann 6 bis 8 Stellen haben. - PIN eingeben - Im Demo-Modus können Sie eine beliebige PIN eingeben. - Erneut probieren - Halten Sie nun Ihre elektronische Gesundheitskarte bereit. - Die Verbindung Ihres Geräts mit dem Server kann je nach Hardware und Internetgeschwindigkeit unterschiedlich lange dauern. - Verbindung mit dem Server herstellen fehlgeschlagen. - Überprüfen Sie Ihre Verbindung mit dem Internet und starten Sie den Vorgang erneut. - Falsche PIN eingegeben. - - Sie haben noch %s weiteren Versuch, bevor Ihre Karte gesperrt wird. - Sie haben noch %s weitere Versuche, bevor Ihre Karte gesperrt wird. - - Falsche CAN eingegeben - Sie finden die Zugangsnummer oben rechts auf Ihrer Gesundheitskarte. - PIN wurde mehrmals falsch eingegeben. - Ihre Gesundheitskarte muss mit der PUK entsperrt werden. - Abbrechen - Suche nach Karte… - Halten Sie die Gesundheitskarte an die Rückseite Ihres Geräts. - Immer noch auf der Suche … - Bewegen Sie langsam die Karte an der Rückseite des Geräts. - Tipp - Gerätehüllen können ggf. die Verbindung über NFC erschweren. - Karte erkannt - Versuchen Sie, die Gesundheitskarte nicht zu bewegen. - 25% - 50% - 75% - 100% - Gesundheitskarte gefunden. Bitte nicht bewegen. - Verbindung abgebrochen - Halten Sie Ihre Gesundheitskarte erneut an die Rückseite des Geräts - Sie haben sich erfolgreich angemeldet - Hinweis: es werden nur die Rezepte aus den letzten 100 Tagen heruntergeladen. - Demo-Modus aktiviert - Sie haben eine NFC-fähige Gesundheitskarte und möchten diese im Demo-Modus ausprobieren? - Weiter mit Karte - Weiter ohne Karte - Demo-Modus aktiviert - Version: %s - Build-Hash: %s - Debug-Menü - Rezeptcode - Lassen Sie diesen Rezeptcode in Ihrer Apotheke abscannen. - Dieser Sammelcode bündelt %s Rezepte - In Apotheke einlösen - Sie stehen in einer Apotheke und möchten Ihr Rezept einlösen. - Bestellen oder reservieren - Senden Sie Ihr Rezept an eine Apotheke und entscheiden Sie, wie Sie Ihre Medikamente erhalten möchten. - Hierfür benötigen Sie eine gültige Gesundheitskarte. - Apotheke wählen - z. B. nach Namen oder Adresse suchen - Leicht Apotheken finden - Standort freigeben und Apotheken in Ihrer Umgebung finden - Standort freigeben - Geöffnet bis %s Uhr - Durchgehend geöffnet - Impressum - Herausgeber - gematik GmbH\nFriedrichstraße 136\n10117 Berlin - Geschäftsführer: Dr. med. Markus Leyck Dieken\nRegistergericht: Amtsgericht Berlin-Charlottenburg\nHandelsregister-Nr.: HRB 96351\nUmsatzsteueridentifikationsnummer: DE241843684 - Verantwortlich für den Inhalt - Dr. med. Markus Leyck Dieken - Kontakt - https://www.das-e-rezept-fuer-deutschland.de/kontakt - app-feedback@gematik.de - Hinweis - Wir bemühen uns um eine geschlechtergerechte Sprache. Sollten Ihnen Fehler auffallen, freuen wir uns über eine Mitteilung per Mail. - Eingescanntes Rezept - Medikament %s - Aktuell - Aktualisieren - Archiv - Sie haben noch keine Rezepte eingelöst - - %s Medikament - %s Medikamente - - Eingelöst am %s - Sie haben noch keine Rezepte eingelöst - Deutschlands moderne Plattform für digitale Medizin - Mail schreiben - Webseite öffnen - Willkommen - Anmeldung starten - Auf Entsperren drücken - Entsperren - Sie haben Frage oder Probleme bei der Nutzung der App? Unsere technische Hotline erreichen Sie unter %s. Viele Fragen haben wir bereits auf %s für Sie beantwortet. - Anmelden - Abbrechen - https://www.das-e-rezept-fuer-deutschland.de/ - das-e-rezept-fuer-deutschland.de - Einstellungen - Name unbekannt - Gesundheitskarten - Karte hinzufügen - Zum Ausprobieren - Der Demo-Modus erlaubt es Ihnen, alle Bereiche der App auch ohne elektronische Gesundheitskarte zu erkunden. - Demo-Modus - Sicherheit - Schützen Sie Ihre Gesundheitsinformationen vor dem Zugriff Unbefugter. - Nicht sichern - Nicht empfohlen - Biometrie - Diese App verwendet den sichersten biometrischen Sensor, der von Ihrem Gerät zur Verfügung gestellt wird. - Gerätesicherung - Nicht empfohlen - Rechtliches - Impressum - Datenschutz - Nutzungsbedingungen - Demo-Modus aktiviert - Unser Demo-Modus zeigt Ihnen alle Funktionen der App – ganz ohne Gesundheitskarte. - Erkundungstour gefällig? - Unser Demo-Modus zeigt Ihnen alle Funktionen der App – ganz ohne Gesundheitskarte. - Demo-Modus starten - Sie haben keine aktuellen Rezepte - Rezeptdaten absichern - Verbesserter Schutz Ihrer Daten durch Fingerabdruck oder Gesichts-Scan. - Jetzt aktivieren - Details - Behalten Sie den Überblick - Markieren Sie dieses Rezept als eingelöst, sobald Sie Ihr Medikament erhalten haben. - Rezepte automatisch aktualisieren - Melden Sie sich an, damit Ihre Rezepte automatisch als eingelöst markiert werden können. - Jetzt anmelden - Weshalb sehe ich nur diese Informationen? - Ihre Gesundheitsinformationen genießen besonderen Schutz - Medikament %1$d - Als eingelöst markieren - Als nicht eingelöst markieren - Von diesem Gerät löschen - Protokoll - Gescannt am - %1$s Uhr - Noch einlösbar bis %s - Details zu diesem Medikament - Darreichungsform - Packungsgröße - Pharmazentralnummer (PZN) - Einnahmehinweise - Bitte beachten Sie die Einnahmehinweise in Ihrem Medikationsplan oder die schriftliche Dosierungsanweisung Ihres Arztes. - Versicherte Person - Name - Adresse - Geburtsdatum - Krankenversicherung / Kostenträger - Status - Versichertennummer - Verschreibende Person - Name - Facharzt / Fachärztin - Arztnummer (LANR) - Institution - Name - Adresse - Betriebsstätten-Nummer - Telefonnummer - Mail - Arbeitsunfall - Unfalltag - Unfallbetrieb- oder Arbeitgebernummer - Möchten Sie dieses Rezept unwiderruflich löschen? - Löschen - Abbrechen - Nur dieses Rezept wieder verfügbar machen oder alle? - Alle - Nur dieses - Hier ist Eile geboten - Dieses Medikament kann ohne Notdienstgebühr auch nachts in einer Apotheke eingelöst werden. - Ersatzpräparat möglich - Ersatzpräparate sind zulässig. Aufgrund gesetzlicher Vorgaben Ihrer Krankenversicherung kann Ihnen eine Alternative ausgehändigt werden. - Verbindlich reservieren - Botendienst anfragen - Per Versand liefern lassen - Bitte beachten Sie, dass auch für verschriebene Medikamente Zuzahlungen anfallen können. - Öffnungszeiten - Webseite - Reservierung - Möchten Sie folgende Rezepte in der %s verbindlich einlösen? - Rezepte - Einlösen - Botendienst - Lieferadresse - Wie können wir helfen? - Hat sich die Lieferadresse geändert? Sie möchten der Apotheke noch etwas mitteilen? - Jetzt anrufen - Ihre Lieferadresse können Sie auf der Webseite der Versand-Apotheke ändern. - Versand - Protokoll - ohne hinterlegte Aktion - abgelaufen - Nur noch heute als Selbstzahlender einlösbar - Rezeptblock umbenennen - Sie können einen Namen für diesen Rezeptblock vergeben. - Anmelden - Anmelden - Ein NFC-fähiges Smartphone mit mindestens Android 7 - NFC aktivieren - Bitte aktivieren Sie die NFC-Funktion Ihres Geräts, um sich mit Ihrer Gesundheitskarte anzumelden. - Aktivieren - Wie erhalte ich eine neue Gesundheitskarte? - Hier hilft Ihnen Ihre Krankenversicherung. - Wie erhalte ich eine PIN? - Eine PIN für Ihre Gesundheitskarte erhalten Sie in einem separaten Brief von Ihrer Krankenversicherung. - Möchten Sie Ihre Zugangsdaten für zukünftige Anmeldungen speichern? - Zugangsdaten speichern - Komfortabel: Hierfür werden Ihre Daten biometrisch auf dem Gerät geschützt - Sicherung nicht möglich - Keine sicheren Sensoren verfügbar oder biometrische Sicherung nicht eingerichtet. - Zugangsdaten nicht speichern - Datensparsam: Erfordert die Eingabe Ihrer Zugangsdaten bei jedem Start der App - Korrigieren - Zur Startseite - Als eingelöst markiert am - Als nicht eingelöst markiert am - Als Einzelcodes anzeigen - Als Sammelcode anzeigen - %s von %s - Rezepte eingelöst? - Möchten Sie die Rezepte als eingelöst markieren? - Nicht eingelöst - Eingelöst - Öffnet um %s Uhr - +49 800 277 377 7 - Technische Hotline - Scanner für Rezepte öffnen - Einstellungen - +49 800 277 377 7 - Bitte identifizieren Sie sich via Fingerabdruck oder Gesichtserkennung. - Hinweis - Diese Änderung wird erst nach einem Neustart der App wirksam. - Okay - Tracking - Helfen Sie uns, diese App besser zu machen. Alle Nutzerdaten werden anonym erhoben und dienen ausschließlich der Verbesserung des Nutzungserlebnisses. - Tracking erlauben - Im Falle eines Absturzes oder eines Fehlers der App sendet uns die App Hinweise zu den Gründen. Zudem werden Betriebssystemversion und Angaben zur verwendeten Hardware gesendet. - Screenshots unterdrücken - Verhindert die Anzeige eines Vorschaubilds beim App-Wechsel - Erlauben Sie E-Rezept Ihr Nutzerverhalten anonym zu analysieren? - Das umfasst Hard- und Softwareinformationen Ihres Telefons, Einstellungen der E-Rezept App sowie Umfang der Nutzung, jedoch niemals Daten über Ihre Person oder Ihre Gesundheit.\nDie Daten werden durch Datenverarbeitungsnehmer nur der gematik GmbH zur Verfügung gestellt und nach 180 Tagen spätestens gelöscht. Sie können die Analyse jederzeit wieder im Menü der App deaktivieren.\nWir können durch diese Daten nachvollziehen, welche Funktionen häufig genutzt werden, und diese verbessern. Ferner können wir einschätzen, wie lange ältere Technik unterstützt werden muss, und wann wir z. B. eine neuere Betriebssystemversion verpflichtend machen können, ohne (zu viele) Nutzer zu betreffen. - Erlauben - - Ihnen wurde %s Medikament verschrieben - Ihnen wurden %s Medikamente verschrieben - - Hier tippen, um sie in einer Apotheke einzulösen - Jetzt einlösen - Alles anzeigen - Verordnung löschen - Als eingelöst markiert - Rückgängig - Mehr anzeigen - Weniger anzeigen - Technische Informationen - Abmelden - Es werden alle Zugangsdaten zum Gesundheitsnetzwerk gelöscht. Ihre Rezeptdaten bleiben erhalten. - Damit werden Ihre Zugangsdaten gelöscht. - Abmelden - Abbrechen - Möchten Sie sich aus der App abmelden? - Sicherheit Ihrer Rezeptdaten - Bitte achten Sie darauf, dass Personen, mit denen Sie gegebenenfalls dieses Gerät teilen und deren biometrische Merkmale auf diesem Gerät gespeichert sein könnten oder die über Geräte-PIN, Wischmuster oder Passwort verfügen, ebenfalls Zugriff auf Ihre Rezepte erhalten. - Verbindlich einlösen? - Hiermit werden Ihre Rezepte an diese Apotheke gesendet. Sie können sie anschließend in keiner anderen Apotheke mehr einlösen. - Abbrechen - Jetzt einlösen - Erfolgreich eingelöst - Die Apotheke wird sich schnellstmöglich mit Ihnen in Verbindung setzen, um Einzelheiten zur Lieferung mit Ihnen zu klären. - Schließen Sie Ihre Bestellung im Browser ab - Wechseln Sie auf die Startseite - Die Versandapotheke erstellt Ihnen einen Warenkorb mit Ihren Medikamenten. Dieser Vorgang kann einige Minuten dauern. - Tippen Sie auf „Warenkorb öffnen“ und schließen Sie Ihre Bestellung auf der Webseite der Apotheke ab. - Zur Startseite - Senden fehlgeschlagen - Wiederholen - Ihre Bestellung liegt üblicherweise zeitnah für Sie bereit. Für einen genauen Termin kontaktieren Sie bitte die Apotheke. - Ihr Warenkorb steht bereit - Abholcode erhalten - Mitteilung erhalten - Abholcode anzeigen - Warenkorb öffnen - Zeigen Sie diesen Code in Ihrer Apotheke vor. - Abholcode - Keine Mitteilungen - Sie haben noch keine Mitteilungen erhalten - Leider war die Nachricht Ihrer Apotheke leer. Bitte kontaktieren Sie Ihre Apotheke. - Kein E-Mail-Programm eingerichtet - Keine Ergebnisse - Unter diesem Suchbegriff konnten wir keine Ergebnisse finden. - Open Source Lizenzen - Kontakt - Technische Hotline anrufen - Mail schreiben - An Umfrage teilnehmen - +49 800 277 377 7 - app-feedback@gematik.de - https://gematik.shortcm.li/app_feedback - Mehr erfahren - Lächelnde Familie - Apotheker hält ein Smartphone in der Hand und freut sich auf Sie. - Hand hält ein Smartphone in der Hand und authentifiziert sich mit der neuen elektronischen Gesundheitskarte in der App - Helfen Sie uns, diese App besser zu machen - Wir wollen: - Nutzerströme in der App %s analysieren, um die Benutzbarkeit zu verbessern. - Abstürze und Fehlermeldungen %s an die Entwickler senden. - Fehlermuster frühzeitig erkennen, für eine Verbesserung der technischen Hotline. - Ich möchte dabei helfen, diese App besser zu machen - Sie können diese Entscheidung in den Systemeinstellungen jederzeit ändern. - anonym - Weiter - Das umfasst Hard- und Softwareinformationen Ihres Telefons, Einstellungen der E-Rezept App sowie Umfang der Nutzung, jedoch niemals Daten über Ihre Person oder Ihre Gesundheit. - Die Daten werden durch Datenverarbeitungsnehmer nur der gematik GmbH zur Verfügung gestellt und nach 180 Tagen spätestens gelöscht. Sie können die Analyse jederzeit wieder im Menü der App deaktivieren. - Wir können durch diese Daten nachvollziehen, welche Funktionen häufig genutzt werden, und diese verbessern. Ferner können wir einschätzen, wie lange ältere Technik unterstützt werden muss, und wann wir z.B. eine neuere Betriebssystemversion verpflichtend machen können, ohne (zu viele) Nutzer zu betreffen. - App verbessern - Ablehnen - Anonyme Analyse bleibt deaktiviert - %s Vielen Dank für Ihre Unterstützung! - \u2661 - Bestellen oder reservieren - Rezepte werden automatisch als eingelöst markiert - Es kann zu einer Verzögerung kommen, bis eingelöste Rezepte im Bereich „Archiv“ angezeigt werden. - Okay - Sie müssen angemeldet sein, um Rezepte zu löschen. - Fehler melden - Fehlerhafte Mitteilung erhalten - Eine Apotheke hat eine Mitteilung in einem fehlerhaften Format versendet. - app-fehlermeldung@ti-support.de - Fehlermeldung aus der E-Rezept App - Liebes Service-Team, ich habe eine Nachricht von einer Apotheke erhalten. Leider konnte ich meinem Nutzer die Nachricht aber nicht mitteilen, da ich sie nicht verstanden habe. Bitte prüft, was hier passiert ist, und helft uns. Vielen Dank! Die E-Rezept App - Die folgenden Informationen würde ich gerne dem Service-Team mitteilen, damit die Fehlersuche durchgeführt werden kann. Bitte beachten Sie, dass wir auch Ihre eMail-Adresse sowie ggf. Ihren Namen erfahren, wenn Sie ihn als Absender der eMail konfiguriert haben. Wenn Sie diese Informationen ganz oder teilweise nicht übermitteln möchten, löschen Sie diese bitte aus der Mail. Alle Daten werden von der gematik GmbH oder deren beauftragten Unternehmen nur zur Bearbeitung dieser Fehlermeldung gespeichert und verarbeitet. Die Löschung erfolgt automatisiert, spätestens 180 Tage nach Erledigung des Tickets. Ihre eMail-Adresse nutzen wir ausschließlich, um mit Ihnen Kontakt in Bezug auf diese Fehlermeldung aufzunehmen. Für Fragen oder eine vorzeitige Löschung können Sie sich jederzeit an den Datenschutzverantwortlichen des E-Rezept Systems wenden. Sie finden weitere Informationen in der E-Rezept App im Menü unter dem Datenschutz-Eintrag. - Fehler 40 42 67336 - Anmelden - Bitte identifizieren Sie sich um Rezepte herunterzuladen. - Eingelöst am %s - Sie haben ein Ersatzpräparat erhalten - Bitte beachten Sie die Einnahmehinweise in Ihrem Medikationsplan oder die schriftliche Dosierungsanweisung Ihres Arztes. - Hinweis für die Apotheken: Die Kontaktdaten und Informationen zu Apotheken beziehen wir von mein-apothekenportal.de des Deutschen Apothekenverbandes e.V. Sie haben einen Fehler entdeckt oder möchten Daten korrigieren? - mein-apothekenportal.de - Mehr erfahren - https://www.mein-apothekenportal.de/ - https://www.gematik.de/anwendungen/e-rezept/faq/meine_apotheke/ - Apotheken - Das hat leider nicht geklappt \uD83D\uDE15 - Bitte probieren Sie es erneut. - Sie haben Fragen oder Probleme bei der Nutzung der App? Unsere technische Hotline erreichen Sie unter %s. - Viele Fragen haben wir bereits auf %s für Sie beantwortet. - Kennwort eingeben - Weiter - Bedienungshilfen - Zoomen - Ermöglicht das Vergrößern der App über das Zusammen- oder Auseinanderziehen der Finger (Pinch-to-Zoom). - Hinweis - Kennwort - Sichern Sie Ihre Daten mit einem selbstgewählten Passwort. - Kennwort - Speichern - Kennwort anzeigen lassen - Kennwort eingeben - Sie können beliebige Zahlen, Buchstaben oder Sonderzeichen verwenden. - Kennwort wiederholen - Kennwortstärke - Empfehlungen: %s - Mail schreiben - Wir freuen uns auf Ihr Feedback - Je konkreter, desto besser - Beim Senden Ihrer Nachricht werden folgende Informationen über genutzte Hardware und Betriebssystem übertragen: - Betriebssystem - Android %s (Entwicklerversion %s) (letztes Sicherheitsupdate %s) - Modell - %s %s (Codename %s) - Modus - Dunkles Design - Helles Design - Sprache - Senden - Feedback - Gesundheitskarte - Verstanden - Neue Gesundheitskarte beantragen - Diese App hilft Ihnen dabei, eine neue elektronische Gesundheitskarte zu beantragen. Es entstehen Ihnen hierbei keine Kosten. - Einlösen bald möglich - Diese Apotheke kann derzeit noch keine E-Rezepte in Empfang nehmen. - E-Rezept - Bereit für das E-Rezept - Aktuell geöffnet - Botendienst - Versand - Filter - Beliebte Filter - Filtern - Möglicherweise ist die Standortfreigabe in den Einstellungen deaktiviert. - Kein Standort verfügbar - Krankenkasse - Versichertennummer - Mail senden - #eGKPIN# Bestellung einer NFC-fähigen Gesundheitskarte inklusive PIN - Sehr geehrte Damen und Herren,\n\nich möchte das E-Rezept der gematik nutzen.\n\nSenden Sie mir hierfür bitte eine NFC-fähige Gesundheitskarte zu, sowie die zugehörige PIN.\n\nIch bitte Sie zudem um die Einleitung eines Identifikationsverfahrens. Sollte das bei der %1$s nicht direkt möglich sein, senden Sie mir bitte detaillierte Informationen zu, wie ich die PIN erhalten kann.\n\nMeine KVNR: %2$s\n\nMit freundlichen Grüßen\n\nIhre Versicherte / Ihr Versicherter - Krankenversicherung wählen - Bitte überprüfen Sie ihre Eingabe - Versichertennummer - Verstanden - erhalten. einlösen. verwalten. - Wiederholtes Passwort stimmt überein - - Noch %s Tag als Selbstzahlender einlösbar - Noch %s Tage als Selbstzahlender einlösbar - - - Noch %s Tag gültig - Noch %s Tage gültig - - Scanner öffnen - Wir verarbeiten Ihre Geräteinformationen!\nZum Lesen des Rezeptcodes nutzt diese App das ML Kit von Google. Wenn Sie „Akzeptieren“ auswählen, stimmen Sie zu, dass Google von Zeit zu Zeit auf Geräteinformationen zu zugreifen und diese zum Zwecke der Nutzungsanalyse, Diagnostik und Konfiguration des ML Kit verarbeiten kann. Sie haben das Recht, ihre Einwilligung jederzeit zu widerrufen, ohne dass die Rechtmäßigkeit der erfolgten Verarbeitung berührt wird. Das Ablehnen führt jedoch dazu, dass die den Rezeptcodescanner nicht verwenden können. - Einverstanden - Abbrechen - Wie erhalte ich eine neue Gesundheitskarte? Hier hilft Ihnen Ihre Krankenversicherung. - Fehler 20 10 76631 - Das Zertifikat Ihrer Gesundheitskarte ist ungültig. Ist Ihre Karte möglicherweise abgelaufen? Bitte kontaktieren Sie Ihre Krankenkasse. - Erfolglose Anmeldeversuche - - Es wurde %s erfolgloser Anmeldeversuche festgestellt. - Es wurden %s erfolglose Anmeldeversuche festgestellt. - - Beste Gerätesicherung wählen - Hierbei kann es sich um Fingerabdruck, Wischmuster oder ähnliches handeln - Die beste verfügbare Geräteabsicherung wurde nicht eingerichtet. Hierbei kann es sich um Fingerabdruck, Wischmuster oder ähnliches handeln - Tokens - Access Token - SSO Token - Kein Access Token verfügbar - kein SSO Token verfügbar - in die Zwischenablage kopiert - Klicken, um den Token in die Zwischenablage zu koperen - Als Selbstzahler noch einlösbar bis %s - nur noch heute gültig - Nicht mehr gültig - Erlauben - Keine Verbindung zum Server - Bitte probieren Sie es in einigen Minuten erneut - Erneut laden - Tokens anzeigen - Wie möchten Sie diese App absichern? - Machen Sie es Unbefugten schwerer, an Ihre Daten zu gelangen und sichern Sie den Start der App. - Oder - Beste Gerätesicherung wählen - Beste Gerätesicherung gewählt - Diese App verwendet die sicherste Methode, die von Ihrem Gerät zur Verfügung gestellt wird. Hierbei kann es sich um Fingerabdruck, Wischmuster oder ähnliches handeln. - Hinweis - Für dieses Gerät wurde keine Gerätesicherung eingerichtet - Wir empfehlen Ihnen, Ihre medizinischen Daten zusätzlich durch eine Gerätesicherung wie beispielsweise einen Code oder Biometrie zu schützen. - Diesen Hinweis in Zukunft nicht mehr anzeigen. - Verbindung fehlgeschlagen. Eine Netzwerkverbindung konnte nicht aufgebaut werden. - Kommunikation mit dem Server fehlgeschlagen: Statuscode %s. - Kommunikation mit dem Server fehlgeschlagen: VAU Fehler - Keine aktiven Tokens - Als eingelöst markiert - Als nicht eingelöst markiert - Warnung - Diesem Gerät darf eventuell nicht voll vertraut werden - Diese App sollte aus Sicherheitsgründen nicht auf gerooteten Geräten genutzt werden. - Ich nehme das erhöhte Risiko zur Kenntnis und möchte dennoch fortfahren. - Weshalb sind Geräte mit Root-Zugriff ein potentielles Sicherheitsrisiko? - Mehr erfahren - https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html - Hinweis - Neue Gesundheitskarte beantragen - Um sich erfolgreich in dieser App anmelden zu können, benötigen Sie - · - Eine %s mit Zugangsnummer (CAN) - Gesundheitskarte - die zugehörige %s. - Pin - - Die Gesundheitskarte und die zugehörige PIN erhalten Sie kostenfrei von Ihrer Krankenversicherung. Der Antrag kann formlos und per %s gestellt werden. - Mail - Mit Gesundheitskarte anmelden - In Kürze: Mit Kassen-App anmelden - Sichere Anmeldung mit Ihrer neuen elektronischen Gesundheitskarte - Nutzen Sie eine App Ihrer Krankenversicherung zur Freischaltung - Wie möchten Sie sich anmelden? - Um automatisch Rezepte zu empfangen und leicht Medikamente online einlösen oder reservieren zu können, müssen Sie sich anmelden. - Anmeldung - Mann meldet sich mit Gesundheitskarte an - Frau meldet sich mit Kassen-App an - Leider hat ihr Gerät kein NFC um diese Funktion zu nutzen. - Name des Profils - Bitte geben Sie einen Namen für das neue Profil ein. - Profilname - Profilnamen ändern - Löschen - Profile - Wie sollen wir Sie nennen? - Das hilft Ihnen dabei, den Überblick zu behalten, wenn Sie die Rezepte für mehrere Personen verwalten möchten. - Vorname und Nachname - Profile verwalten - Legen Sie Profile für Ihre Familie oder Angehörige an. Melden Sie sich mit der Gesundheitskarte an, um online bestellen zu können. - Profil hinzufügen - Speichern - Gesundheitskarte - Krankenversicherung kontaktieren - Um sich in dieser App anmelden zu können, benötigen Sie eine NFC-fähige Gesundheitskarte sowie eine zugehörige PIN. - Diese erhalten Sie kostenfrei von Ihrer Krankenversicherung. Hierfür müssen Sie sich mittels amtlichem Ausweisdokument identifiziert haben. - So erkennen Sie eine NFC-fähige Gesundheitskarte - https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204 - Krankenversicherung wählen - Keine Auswahl - Was möchten Sie beantragen? - Keine Kontaktaufnahme über diese App möglich - Bitte nutzen Sie die üblichen Kanäle, um Ihre Versicherung zu kontaktieren. - Gesundheitskarte & PIN - Nur PIN - Kontaktieren Sie Ihre Krankenversicherung - Anmeldung in der E-Rezept App - Neue Gesundheitskarte bestellen - Für die Anmeldung benötigen Sie eine geeignete Karte mit NFC. Wir unterstützen Sie bei der Bestellung. - Fortfahren - Das Namensfeld darf nicht leer sein. - Ein Profil mit dem eingegebenen Namen existiert bereits. - Profil - %s ausgewählt - Hintergrundfarbe - Frühlingsgrau - Sonnentau - Es! Ist! Rosa! - Baum - Blauer Mond September - Nicht angemeldet - Verbunden - Zuletzt verbunden am %s - Profil löschen? - Hiermit werden alle Daten des Profils auf diesem Gerät gelöscht. Ihre Rezepte im Gesundheitsnetzwerk bleiben erhalten. - Löschen - Abbrechen - Profil löschen - Sie möchten das letzte Profil löschen. - Die App benötigt mindestens ein Profil. Bitte geben Sie einen Namen für das neue Profil ein. - Fehler 20 10 76831 - Das Verzeichnis der Gesundheitskarten konnte nicht erreicht werden. Bitte versuchen Sie es erneut. - Hiermit stellen Sie eine Verbindung zum Gesundheitsnetzwerk her. Sie erhalten dadurch automatisch neue Rezepte oder Nachrichten. - Fachlich geprüfte Informationen zu Krankheiten, ICD-Codes und zu Vorsorge- und Pflegethemen finden Sie im Nationalen Gesundheitsportal. - gesund.bund.de öffnen - https://gesund.bund.de/ - Wir haben die Datenschutzbestimmungen geändert - Die E-Rezept App hat sich weiterentwickelt. Dadurch ist es notwendig geworden, unsere Datenschutzbestimmungen zu aktualisieren. - Datenschutzbestimmungen öffnen - Das hat sich seit dem %s geändert: - Was passiert, wenn Sie die App öffnen? - Die Erhebung Ihrer IP-Adresse durch unser System ist notwendig, um Ihnen die Nutzung unserer App zu ermöglichen. Bei dem Aufruf und während der Nutzung unserer App synchronisiert unser Server die Daten mit der App auf Ihrem Endgerät, damit Ihnen die aktuellen Informationen zu Ihren E-Rezepten zur Verfügung stehen. Dabei verarbeiten wird Ihre IP-Adresse, Ihr Internet-Service-Provider und das Datum und die Uhrzeit des Zugriffs.\n\n\nWir führen bei jedem Start der E-Rezept App eine Integritätsprüfung Ihres Gerätes durch. Smartphones können mit einem modifiziertem und somit potenziell unsicheren Betriebssystem ausgestattet werden. Nicht jeder Nutzer ist sich bewusst (z.B. bei gebraucht gekauften Geräten), dass sein Gerät „gerootet“ ist, und welche möglichen Gefahren damit einhergehen. Daher nutzen wir Google SafetyNet, um die Integrität des Gerätes zu prüfen, und informieren Nutzer, wenn ihr Gerät betroffen ist.\n\n\nUm die Integrität zu prüfen, erhebt Google SafetyNet diverse Informationen über das Gerät und das installierte Betriebssystem, und leitet diese zur Integritätsprüfung an eigene Server. Diese Server befinden sich möglicherweise im außereuropäischen Ausland und unterliegen anderen Datenschutzgrundsätzen. - Was passiert, wenn ich die Kamerafunktion nutze / Rezepte mit der Kamera auslese? - Wir verwenden ML Kit von Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland (\"Google\"), um den Rezept-QR-Code zu lesen. Der Rezeptcode ist eine eindeutige Identifizierung Ihres Rezeptes. Er kann verglichen werden mit der Nummer eines Schließfachs. Um diesen Code komfortabel und schnell auszulesen, wird das ML Kit genutzt. Die Verarbeitung des Rezeptcodes findet ausschließlich auf Ihrem Gerät statt. \n\nBei dem ersten Starten des Rezeptcodescanners in unserer App, wird ML Kit auf Ihre Gerät heruntergeladen. Zu diesem Zweck erhebt Google Ihre IP-Adresse. Die Verarbeitung dient der Bereitstellung des Dienstes.\n\nDarüber hinaus erhebt Google folgende nicht-personenbezogene Informationen zum Zwecke der Nutzungsanalyse, Diagnostik und Konfiguration des ML Kit:\n - Geräteinformationen (z. B. Hersteller, Gerätemodell, Betriebssystemversion, Hardware, Mobilfunkbetreiber, Zeitzone und Spracheinstellungen)\n - Informationen über die Applikation (z.B. Version der App)\n - Informationen über die Konfiguration von ML Kit\n - Fehlermeldungen\n - Ereignistypen (initialisieren, Modell herunterladen, aktualisieren, ausführen, Erkennung)\n - Technische Leistungsdaten Ihres Gerätes\n - IP-Adresse (wird nur temporär gespeichert)\n - Weitere Daten, insbesondere Ihre Rezeptdaten, werden nicht von Google erhoben.\n\nDie Verarbeitung Ihrer Informationen wird nicht nur von Google Ireland Limited, sondern kann auch von Google LLC in den USA durchgeführt werden. Weiterführendes unter 7. Übermittlung in Drittländer. - https://policies.google.com/privacy/frameworks - https://support.google.com/policies/contact/general_privacy_form - Profil wählen - Profile bearbeiten - Keine neuen Rezepte verfügbar - - %s Rezept aktualisiert - %s Rezepte aktualisiert - - Einlösbar - Rezept ist übertragen - Eingelöst - Unbekannt - Details - Zugriffsprotokolle anzeigen - Hier können Sie sehen, wer auf Ihre Rezepte zugegriffen hat - Hierbei handelt es sich um Zugangsschlüssel zum Rezeptdienst - Zugriffsprotokolle - Ausloggen - Einloggen - Die Funktion steht im Demomodus nicht zur Verfügung - Das Rezept wurde übertragen. - Das Rezept wird von Ihrem Arzt / Ihrer Ärztin direkt an die Apotheke weitergeleitet. - Keine Zugriffsprotokolle - Sie erhalten Zugriffsprotokolle, wenn Sie am Rezeptdienst angemeldet sind. - Es liegen noch keine Zugriffsprotokolle vor. - Zuletzt aktualisiert am %s - Das Rezept ist derzeit in Bearbeitung und kann nicht gelöscht werden - Dieses Profil wurde noch nicht mit einer Versichertennummer verbunden. Hierfür müssen Sie sich am Rezeptserver anmelden. - Verknüpft mit: - Am Rezeptserver anmelden? - Komfortabel neue Rezepte empfangen und einlösen. - Anmelden - Im Demomodus nicht verfügbar. - Diese Funktion wird in einem kommenden Update freigeschaltet. - Akzeptieren - Das hat anscheinend nicht geklappt - Uns ist bewusst, dass die Verbindung mit der Gesundheitskarte ihre Tücken hat. In Zukunft soll die Anmeldung daher auch über eine bereits authentifizierte Krankenkassen-App möglich sein.\n\nWir arbeiten außerdem daran, dass Rezepte auch ohne Anmeldung digital eingelöst werden können.\n\nIst Ihnen im Verlauf dieses Prozesses etwas aufgefallen, dass Sie uns gerne mitteilen wollen? Bitte schreiben Sie uns, wir freuen uns auch über sehr kritisches Feedback. - Verbindungs-Tipps - Verbessern Sie die Stärke der Verbindung - Entfernen Sie ggf. die Schutzhülle. - Vibriert das Gerät und bricht die Verbindung anschließend ab, suchen Sie in einem geringen Radius nach der optimalen Position. - Bewegen Sie das Gerät nur sehr langsam über die Karte. - Legen Sie das Gerät direkt auf die Karte. - Platzieren Sie die Gesundheitskarte dafür auf einer ebenen Unterlage (z.B. einem Tisch). - Verbessern Sie die Stärke der Verbindung - Beachten Sie die Platzierung des NFC-Sensors - Finden Sie heraus, wo sich in Ihrem Gerät der NFC-Sensor befindet (hier z.B. eine Übersicht für Geräte von %s). - Samsung - Teilweise kann sich die Position des NFC-Sensors innerhalb einer Modellreihe unterscheiden (hier z.B. die Angaben für das %s). - Google Pixel - https://www.samsung.com/hk_en/nfc-support/ - - Nächster Tipp - Weiter - Schließen - Ausprobieren - Schreiben Sie uns - Einlösen - Gescanntes Rezept - Gescannt am %s - Als eingelöst markiert am %s - Mitteilung erhalten + E-Rezept + Okay + Abbrechen + Zurück + um + Digital. Schnell. Sicher. + Rezepte hinzufügen + Sie haben einen Rezept-Ausdruck erhalten? Rezepte fügen Sie der App hinzu, indem Sie den jeweiligen Rezeptcode abscannen. + Verstanden + Task-ID + Access-Code + Nutzungsbedingungen + Datenschutzerklärung + Rezepte + Zugriff auf Kamera verweigert + Um den Scanner verwenden zu können, müssen Sie der App in den Systemeinstellungen den Zugriff auf Ihre Kamera gestatten. + Fokussieren Sie mit der Kamera auf einen Rezeptcode + Hierbei handelt es sich um keinen gültigen Rezeptcode + Dieser Rezeptcode wurde bereits abgescannt + + %s Rezept erkannt + %s Rezepte erkannt + + Abbrechen + Kameralicht + Scannen abbrechen? + okay + Nicht abbrechen + Los geht’s + Was Sie benötigen: + Zugangsnummer eingeben + PIN eingeben + Erneut probieren + Verbindung mit dem Server herstellen fehlgeschlagen. + Falsche PIN eingegeben. + + Sie haben noch %s weiteren Versuch, bevor Ihre Karte gesperrt wird. + Sie haben noch %s weitere Versuche, bevor Ihre Karte gesperrt wird. + + Falsche CAN eingegeben + Sie finden die Zugangsnummer oben rechts auf Ihrer Gesundheitskarte. + Abbrechen + Suche nach Karte… + Halten Sie die Gesundheitskarte an die Rückseite Ihres Geräts. + Immer noch auf der Suche … + Bewegen Sie langsam die Karte an der Rückseite des Geräts. + Tipp + Gerätehüllen können ggf. die Verbindung über NFC erschweren. + Karte erkannt + Versuchen Sie, die Gesundheitskarte nicht zu bewegen. + 25% + 50% + 75% + 100% + Gesundheitskarte gefunden. Bitte nicht bewegen. + Verbindung abgebrochen + Halten Sie Ihre Gesundheitskarte erneut an die Rückseite des Geräts + Version: %s + Build-Hash: %s + Debug-Menü + Rezeptcode + Lassen Sie diesen Rezeptcode in Ihrer Apotheke abscannen. + Dieser Sammelcode bündelt %s Rezepte + In Apotheke einlösen + Sie stehen in einer Apotheke und möchten Ihr Rezept einlösen. + Bestellen oder reservieren + Senden Sie Ihr Rezept an eine Apotheke und entscheiden Sie, wie Sie Ihre Medikamente erhalten möchten. + Standort freigeben und Apotheken in Ihrer Umgebung finden + Standort freigeben + Geöffnet bis %s Uhr + Durchgehend geöffnet + Impressum + Herausgeber + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Geschäftsführer: Dr. med. Markus Leyck Dieken\nRegistergericht: Amtsgericht Berlin-Charlottenburg\nHandelsregister-Nr.: HRB 96351\nUmsatzsteueridentifikationsnummer: DE241843684 + Verantwortlich für den Inhalt + Dr. med. Markus Leyck Dieken + Kontakt + https://www.das-e-rezept-fuer-deutschland.de/kontakt + app-feedback@gematik.de + Hinweis + Wir bemühen uns um eine geschlechtergerechte Sprache. Sollten Ihnen Fehler auffallen, freuen wir uns über eine Mitteilung per Mail. + Deutschlands moderne Plattform für digitale Medizin + Mail schreiben + Webseite öffnen + Willkommen + Anmeldung starten + Entsperren + Anmelden + Abbrechen + Sicherheit + Rechtliches + Impressum + Datenschutz + Nutzungsbedingungen + Details + Als eingelöst markieren + Als nicht eingelöst markieren + Darreichungsform + Packungsgröße + Versicherte Person + Name + Adresse + Geburtsdatum + Krankenversicherung / Kostenträger + Status + Versichertennummer + Verschreibende Person + Name + Facharzt / Fachärztin + Arztnummer (LANR) + Institution + Name + Adresse + Betriebsstätten-Nummer + Telefonnummer + Mailadresse + Arbeitsunfall + Unfalltag + Unfallbetrieb- oder Arbeitgebernummer + Möchten Sie dieses Rezept unwiderruflich löschen? + Löschen + Abbrechen + Ersatzpräparate sind zulässig. Aufgrund gesetzlicher Vorgaben Ihrer Krankenversicherung kann Ihnen eine Alternative ausgehändigt werden. + Verbindlich reservieren + Botendienst anfragen + Per Versand liefern lassen + Bitte beachten Sie, dass auch für verschriebene Medikamente Zuzahlungen anfallen können. + Öffnungszeiten + Webseite + Möchten Sie folgende Rezepte in der %s verbindlich einlösen? + Nur noch heute als Selbstzahlender einlösbar + Anmelden + NFC aktivieren + Bitte aktivieren Sie die NFC-Funktion Ihres Geräts, um sich mit Ihrer Gesundheitskarte anzumelden. + Aktivieren + Korrigieren + Als Einzelcodes anzeigen + Als Sammelcode anzeigen + %s von %s + Rezepte eingelöst? + Möchten Sie die Rezepte als eingelöst markieren? + Nicht eingelöst + Eingelöst + Öffnet um %s Uhr + +49 800 277 377 7 + Technische Hotline + Scanner für Rezepte öffnen + Einstellungen + Hinweis + Diese Änderung wird erst nach einem Neustart der App wirksam. + Okay + Screenshots unterdrücken + Verhindert die Anzeige eines Vorschaubilds beim App-Wechsel + Erlauben Sie E-Rezept Ihr Nutzerverhalten anonym zu analysieren? + Technische Informationen + Sicherheit Ihrer Rezeptdaten + Bitte achten Sie darauf, dass Personen, mit denen Sie gegebenenfalls dieses Gerät teilen und deren biometrische Merkmale auf diesem Gerät gespeichert sein könnten, ebenfalls Zugriff auf Ihre Rezepte erhalten. + Senden fehlgeschlagen + Kein E-Mail-Programm eingerichtet + Keine Ergebnisse + Unter diesem Suchbegriff konnten wir keine Ergebnisse finden. + Open Source Lizenzen + Kontakt + Technische Hotline anrufen + An Umfrage teilnehmen + +49 800 277 377 7 + app-feedback@gematik.de + Mehr erfahren + Ich möchte dabei helfen, diese App besser zu machen + Das umfasst Hard- und Softwareinformationen Ihres Telefons, Einstellungen der E-Rezept App sowie Umfang der Nutzung, jedoch niemals Daten über Ihre Person oder Ihre Gesundheit. + Die Daten werden durch Datenverarbeitungsnehmer nur der gematik GmbH zur Verfügung gestellt und spätestens nach 180 Tagen gelöscht. Sie können die Analyse jederzeit wieder im Menü der App deaktivieren. + Wir können durch diese Daten nachvollziehen, welche Funktionen häufig genutzt werden, und diese verbessern. Ferner können wir einschätzen, wie lange ältere Technik unterstützt werden muss, und wann wir z.B. eine neuere Betriebssystemversion verpflichtend machen können, ohne (zu viele) Nutzer zu betreffen. + App verbessern + Anonyme Analyse bleibt deaktiviert + %s Vielen Dank für Ihre Unterstützung! + \u2661 + Hinweis + Es kann zu einer Verzögerung kommen, bis eingelöste Rezepte in das Archiv verschoben werden. + Okay + Anmelden + Bitte identifizieren Sie sich um Rezepte herunterzuladen. + Eingelöst am %s + Hinweis für die Apotheken: Die Kontaktdaten und Informationen zu Apotheken beziehen wir von mein-apothekenportal.de des Deutschen Apothekenverbandes e.V. Sie haben einen Fehler entdeckt oder möchten Daten korrigieren? + mein-apothekenportal.de + Mehr erfahren + https://www.mein-apothekenportal.de/ + https://www.gematik.de/anwendungen/e-rezept/faq/meine-apotheke/ + Apotheken + Das hat leider nicht geklappt \uD83D\uDE15 + Bitte probieren Sie es erneut. + Kennwort eingeben + Weiter + Bedienungshilfen + Zoomen + Ermöglicht das Vergrößern der App über das Zusammen- oder Auseinanderziehen der Finger (Pinch-to-Zoom). + Hinweis + Kennwort + Sichern Sie Ihre Daten mit einem selbstgewählten Passwort. + Kennwort + Speichern + Kennwort anzeigen lassen + Kennwort wiederholen + Empfehlungen: %s + Mail schreiben + Beim Senden Ihrer Nachricht werden folgende Informationen über genutzte Hardware und Betriebssystem übertragen: + Einlösen bald möglich + Diese Apotheke kann derzeit noch keine E-Rezepte in Empfang nehmen. + Aktuell geöffnet + Botendienst + Versand + Filter + Filtern + Kein Standort verfügbar + Verstanden + Wiederholtes Passwort stimmt überein + + Noch %s Tag als Selbstzahlender einlösbar + Noch %s Tage als Selbstzahlender einlösbar + + + Noch %s Tag gültig + Noch %s Tage gültig + + Scanner öffnen + Wir verarbeiten Ihre Geräteinformationen!\nZum Lesen des Rezeptcodes nutzt diese App das ML Kit von Google. Wenn Sie „Akzeptieren“ auswählen, stimmen Sie zu, dass Google von Zeit zu Zeit auf Geräteinformationen zugreifen und diese zum Zwecke der Nutzungsanalyse, Diagnostik und Konfiguration des ML Kit verarbeiten kann. Sie haben das Recht, Ihre Einwilligung jederzeit zu widerrufen, ohne dass die Rechtmäßigkeit der erfolgten Verarbeitung berührt wird. Das Ablehnen führt jedoch dazu, dass Sie den Rezeptcodescanner nicht verwenden können. + Einverstanden + Abbrechen + Fehler 20 10 76631 + Das Zertifikat Ihrer Gesundheitskarte ist ungültig. Ist Ihre Karte möglicherweise abgelaufen? Bitte kontaktieren Sie Ihre Krankenversicherung. + Erfolglose Anmeldeversuche + + Es wurde %s erfolgloser Anmeldeversuche festgestellt. + Es wurden %s erfolglose Anmeldeversuche festgestellt. + + Beste Gerätesicherung wählen + Hierbei kann es sich um Fingerabdruck, Wischmuster oder ähnliches handeln + Tokens + Access Token + SSO Token + Kein Access Token verfügbar + kein SSO Token verfügbar + in die Zwischenablage kopiert + Klicken, um den Token in die Zwischenablage zu koperen + Nur noch heute gültig + Erlauben + Keine Verbindung zum Server + Bitte probieren Sie es in einigen Minuten erneut + Erneut laden + Tokens anzeigen + Wie möchten Sie die App absichern? + Hinweis + Für dieses Gerät wurde keine Gerätesicherung eingerichtet + Wir empfehlen Ihnen, Ihre medizinischen Daten zusätzlich durch eine Gerätesicherung wie beispielsweise einen Code oder Biometrie zu schützen. + Diesen Hinweis in Zukunft nicht mehr anzeigen. + Verbindung fehlgeschlagen. Eine Netzwerkverbindung konnte nicht aufgebaut werden. + Kommunikation mit dem Server fehlgeschlagen: Statuscode %s. + Kommunikation mit dem Server fehlgeschlagen: VAU Fehler + Warnung + Diesem Gerät darf eventuell nicht voll vertraut werden + Diese App sollte aus Sicherheitsgründen nicht auf gerooteten Geräten genutzt werden. + Ich nehme das erhöhte Risiko zur Kenntnis und möchte dennoch fortfahren. + Weshalb sind Geräte mit Root-Zugriff ein potentielles Sicherheitsrisiko? + Mehr erfahren + https://www.bsi.bund.de/SharedDocs/Glossareintraege/DE/R/Rooten.html + Name des Profils + Bitte geben Sie einen Namen für das neue Profil ein. + Profilname + Profile + Profil hinzufügen + Gesundheitskarte + Krankenversicherung kontaktieren + Um sich in dieser App anmelden zu können, benötigen Sie eine NFC-fähige Gesundheitskarte sowie eine zugehörige PIN. + Diese erhalten Sie kostenfrei von Ihrer Krankenversicherung. Hierfür müssen Sie sich mittels amtlichem Ausweisdokument identifiziert haben. + So erkennen Sie eine NFC-fähige Gesundheitskarte + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204 + Krankenversicherung wählen + Auswahl treffen + Was möchten Sie beantragen? + Keine Kontaktaufnahme über diese App möglich + Bitte nutzen Sie die üblichen Kanäle, um Ihre Versicherung zu kontaktieren. + Gesundheitskarte & PIN + Nur PIN + Kontaktieren Sie Ihre Krankenversicherung + Anmeldung in der E-Rezept App + Das Namensfeld darf nicht leer sein. + Ein Profil mit dem eingegebenen Namen existiert bereits. + Profil + %s ausgewählt + Hintergrundfarbe + Frühlingsgrau + Sonnentau + Es! Ist! Rosa! + Baum + Blauer Mond September + Nicht angemeldet + Verbunden + Zuletzt verbunden am %s + Profil löschen? + Hiermit werden alle Daten des Profils auf diesem Gerät gelöscht. Ihre Rezepte im Gesundheitsnetzwerk bleiben erhalten. + Löschen + Abbrechen + Profil löschen + Sie möchten das letzte Profil löschen. + Die App benötigt mindestens ein Profil. Bitte geben Sie einen Namen für das neue Profil ein. + Fehler 20 10 76831 + Das Verzeichnis der Gesundheitskarten konnte nicht erreicht werden. Bitte versuchen Sie es erneut. + Fachlich geprüfte Informationen zu Krankheiten, ICD-Codes und zu Vorsorge- und Pflegethemen finden Sie im Nationalen Gesundheitsportal. + gesund.bund.de öffnen + https://gesund.bund.de/ + Wir haben die Datenschutzbestimmungen geändert + Die E-Rezept App hat sich weiterentwickelt. Dadurch ist es notwendig geworden, unsere Datenschutzbestimmungen zu aktualisieren. + Datenschutzbestimmungen öffnen + Das hat sich seit dem %s geändert: + Was passiert, wenn Sie die App öffnen? + Die Erhebung Ihrer IP-Adresse durch unser System ist notwendig, um Ihnen die Nutzung unserer App zu ermöglichen. Bei dem Aufruf und während der Nutzung unserer App synchronisiert unser Server die Daten mit der App auf Ihrem Endgerät, damit Ihnen die aktuellen Informationen zu Ihren E-Rezepten zur Verfügung stehen. Dabei verarbeiten wird Ihre IP-Adresse, Ihr Internet-Service-Provider und das Datum und die Uhrzeit des Zugriffs.\n\n\nWir führen bei jedem Start der E-Rezept App eine Integritätsprüfung Ihres Gerätes durch. Smartphones können mit einem modifiziertem und somit potenziell unsicheren Betriebssystem ausgestattet werden. Nicht jeder Nutzer ist sich bewusst (z.B. bei gebraucht gekauften Geräten), dass sein Gerät „gerootet“ ist, und welche möglichen Gefahren damit einhergehen. Daher nutzen wir Google SafetyNet, um die Integrität des Gerätes zu prüfen, und informieren Nutzer, wenn ihr Gerät betroffen ist.\n\n\nUm die Integrität zu prüfen, erhebt Google SafetyNet diverse Informationen über das Gerät und das installierte Betriebssystem, und leitet diese zur Integritätsprüfung an eigene Server. Diese Server befinden sich möglicherweise im außereuropäischen Ausland und unterliegen anderen Datenschutzgrundsätzen. + Was passiert, wenn ich die Kamerafunktion nutze / Rezepte mit der Kamera auslese? + Wir verwenden ML Kit von Google Ireland Limited, Gordon House, Barrow Street, Dublin 4, Irland (\"Google\"), um den Rezept-QR-Code zu lesen. Der Rezeptcode ist eine eindeutige Identifizierung Ihres Rezeptes. Er kann verglichen werden mit der Nummer eines Schließfachs. Um diesen Code komfortabel und schnell auszulesen, wird das ML Kit genutzt. Die Verarbeitung des Rezeptcodes findet ausschließlich auf Ihrem Gerät statt. \n\nBei dem ersten Starten des Rezeptcodescanners in unserer App, wird ML Kit auf Ihre Gerät heruntergeladen. Zu diesem Zweck erhebt Google Ihre IP-Adresse. Die Verarbeitung dient der Bereitstellung des Dienstes.\n\nDarüber hinaus erhebt Google folgende nicht-personenbezogene Informationen zum Zwecke der Nutzungsanalyse, Diagnostik und Konfiguration des ML Kit:\n - Geräteinformationen (z. B. Hersteller, Gerätemodell, Betriebssystemversion, Hardware, Mobilfunkbetreiber, Zeitzone und Spracheinstellungen)\n - Informationen über die Applikation (z.B. Version der App)\n - Informationen über die Konfiguration von ML Kit\n - Fehlermeldungen\n - Ereignistypen (initialisieren, Modell herunterladen, aktualisieren, ausführen, Erkennung)\n - Technische Leistungsdaten Ihres Gerätes\n - IP-Adresse (wird nur temporär gespeichert)\n - Weitere Daten, insbesondere Ihre Rezeptdaten, werden nicht von Google erhoben.\n\nDie Verarbeitung Ihrer Informationen wird nicht nur von Google Ireland Limited, sondern kann auch von Google LLC in den USA durchgeführt werden. Weiterführendes unter 7. Übermittlung in Drittländer. + https://policies.google.com/privacy/frameworks + https://support.google.com/policies/contact/general_privacy_form + Profil wählen + Profile bearbeiten + Keine neuen Rezepte verfügbar + + %s Rezept aktualisiert + %s Rezepte aktualisiert + + Einlösbar + In Einlösung + Eingelöst + Unbekannt + Details + Zugriffsprotokolle anzeigen + Wer hat wann auf Ihre Rezepte zugegriffen? + Zugangsschlüssel zum Rezeptdienst + Zugriffsprotokolle + Keine Zugriffsprotokolle + Sie erhalten Zugriffsprotokolle, wenn Sie am Rezeptdienst angemeldet sind. + Es liegen noch keine Zugriffsprotokolle vor. + Zuletzt aktualisiert am %s + Das Rezept ist derzeit in Bearbeitung und kann nicht gelöscht werden + Akzeptieren + Das hat anscheinend nicht geklappt + Uns ist bewusst, dass die Verbindung mit der Gesundheitskarte ihre Tücken hat. In Zukunft soll die Anmeldung daher auch über eine bereits authentifizierte Krankenversicherungs-App möglich sein.\n\nWir arbeiten außerdem daran, dass Rezepte auch ohne Anmeldung digital eingelöst werden können.\n\nIst Ihnen im Verlauf dieses Prozesses etwas aufgefallen, dass Sie uns gerne mitteilen wollen? Bitte schreiben Sie uns, wir freuen uns auch über sehr kritisches Feedback. + Verbindungs-Tipps + Verbessern Sie die Stärke der Verbindung + Entfernen Sie ggf. die Schutzhülle. + Vibriert das Gerät und bricht die Verbindung anschließend ab, suchen Sie in einem geringen Radius nach der optimalen Position. + Bewegen Sie das Gerät nur sehr langsam über die Karte. + Legen Sie das Gerät direkt auf die Karte. + Platzieren Sie die Gesundheitskarte dafür auf einer ebenen Unterlage (z.B. einem Tisch). + Verbessern Sie die Stärke der Verbindung + Beachten Sie die Platzierung des NFC-Sensors + Finden Sie heraus, wo sich in Ihrem Gerät der NFC-Sensor befindet (hier z.B. eine Übersicht für Geräte von %s). + Samsung + Teilweise kann sich die Position des NFC-Sensors innerhalb einer Modellreihe unterscheiden (hier z.B. die Angaben für das %s). + Google Pixel + https://www.samsung.com/hk_en/nfc-support/ + + Nächster Tipp + Weiter + Schließen + Ausprobieren + Schreiben Sie uns + Lizenz Apothekensuche + Einlösen + Gescanntes Rezept + Gescannt am %s + Als eingelöst markiert am %s Wie möchten Sie fortfahren? Bestellen Demnächst verfügbar @@ -708,9 +379,446 @@ Weiter mit %s Rezept Weiter mit %s Rezepten - Authorization mit externem Anbieter wird durchgeführt - Authentisierung durch Krankenkassen-App ausstehend Verbinden der Gesundheitskarte fehlgeschlagen Das aktuelle Profil ist bereits mit einer anderen Gesundheitskarte (Krankenversicherungsnummer %s) verbunden. Ihre Gesundheitskarte ist bereits mit einem anderen Profil verbunden. Wechseln Sie zu Profil %s. + Meine Bestellung + Jetzt reservieren + Jetzt bestellen + Speichern + Kontaktdaten und Adresse + Kontakt + Telefonnummer + Bitte geben Sie für die Kontaktaufnahme eine Telefonnummer an. + Mailadresse (optional) + Lieferadresse + Vorname und Nachname + Bitte geben Sie für die Kontaktaufnahme einen Vor- und Nachnamen an. + Straße und Hausnummer + Bitte geben Sie für die Kontaktaufnahme eine Straße und Hausnummer an. + Adresszusatz (optional) + PLZ und Ort + Bitte geben Sie für die Kontaktaufnahme eine Postleitzahl und den Ort an. + Lieferanweisung (optional) + Hiermit wird Ihr Rezept an diese Apotheke gesendet. Sie können es anschließend in keiner anderen Apotheke mehr einlösen. + Kontaktdaten und Lieferadresse + Rezepte + Wir benötigen Ihre Kontaktdaten zur Beratung durch die Apotheke und um Sie über den aktuellen Stand Ihrer Bestellung zu informieren. + Kontaktdaten angeben + Weitere Kontaktdaten benötigt + Bestellung erfolgreich übermittelt + Ihre Apotheke wird sich bald mit Ihnen in Verbindung setzen. + Schließen + Änderungen verwerfen? + Verwerfen + Für die Suche nutzt das Apothekenverzeichnis Geokoordinaten, die mit Hilfe von OpenStreetMap ermittelt wurden. Wir danken dem Projekt für diese Hilfe. + © OpenStreetMap (%s) + https://www.openstreetmap.org/copyright + Nutzung & Datenschutz + Weiter + Ihre PIN haben Sie in einem Brief von Ihrer Krankenversicherung erhalten. + Keine PIN erhalten + PIN + Überprüfen Sie die Verbindung mit dem Internet und die Uhrzeit-/Datumseinstellung Ihres Geräts. + Um sich zu authentifizieren, drücken Sie auf “Entsperren”. + Ausgesperrt? Bitte überprüfen Sie Ihre biometrischen Zugangsdaten auf diesem Gerät. + Passwort vergessen? Bitte löschen Sie die App und installieren sie anschließend erneut. Weshalb das so ist, erfahren Sie in unserem %s. + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten + Hilfebereich + Packungsgröße und Einheit + Wirkstoff + Wirkstoffmenge + Chargenbezeichnung + Verwendbar bis + Kategorie + Impfstoff + Akzeptieren + Rückgängig + Hinweis + Helfen Sie uns, diese App besser zu machen? + Kennwort eingeben + Das Kennwort muss mindestens acht Zeichen lang sein + Kennwortstärke nicht ausreichend + Kennwortstärke ausreichend + Passwort ist sichtbar + Passwort ist nicht sichtbar + Biometrie + Kennwort + Warte auf Antwort + Keine Rezepte + Sie haben derzeit keine einlösbaren Rezepte. + Aktualisieren + Automatische Abmeldung + Aus Sicherheitsgründen wird die Verbindung zum Rezeptserver nach 12 Stunden getrennt. Verbinden Sie sich erneut, um aktuelle Rezepte abzurufen. + Verbinden + Haben Sie einen Papierausdruck erhalten? + Fügen Sie Ihrer Liste Rezepte hinzu, indem Sie auf den Scan-Button in der rechten oberen Ecke tippen. + Papierausdruck einscannen + Um Rezepte automatisch zu erhalten, müssen Sie angemeldet sein. + Anmelden + Keine eingelösten Rezepte + Hier werden Ihre eingelösten Rezepte angezeigt. Aus Datenschutzgründen werden Ihre Rezepte nach 100 Tagen vom Rezepteserver gelöscht. + Keine eingelösten Rezepte + Hier werden Ihre eingelösten Rezepte angezeigt. Fügen Sie per Scan Rezepte hinzu, um mit dem Einlösen zu beginnen. + Geräteverwaltung + Verbundene Geräte + Registriert seit %s (dieses Gerät) + Registriert seit %s + Aktuell + Archiv + Erneut einlösen? + Hinweis: Die Apotheke, die ein Rezept als erste akzeptiert, blockiert es für eine Bearbeitung durch eine weitere Apotheke. + Abbrechen + Okay + + Sie haben das Rezept %s bereits an eine Apotheke gesendet. Dennoch erneut einlösen? + Sie haben einige dieser Rezepte bereits an eine Apotheke gesendet. Dennoch an weitere Apotheke senden? + + Aus Sicherheitsgründen wird die Verbindung zum Rezeptserver nach 12 Stunden getrennt. Für eine erneute Verbindung benötigen Sie für jeden Verbindungsvorgang Gesundheitskarte und PIN. + PIN + PIN (Gesundheitskarte) eingeben + Weiter + Authentisierung + Verbundene Geräte + Gerät entfernen? + Abbrechen + Entfernen + Dieses Gerät entfernen? + Möchten Sie %s entfernen? + Wenn Sie %s entfernen, wird in spätestens 12 Stunden die Verbindung zum Rezeptserver dauerhaft getrennt. + Geräte werden geladen… + Keine Geräte + Es sind keine Geräte mit dieser Gesundheitskarte verbunden. + Erneut versuchen + Uh oh :-( + Geräteliste konnte nicht geladen werden. + wwweg… + Keine Internetverbindung. + Arznei- und Verbandmittel + Betäubungsmittel + Abgabe rezeptpflichtiger Arzneimittel nach § 4 AMVV + Brauchen Sie Hilfe? + Wir haben für Sie einige Tipps zusammengestellt, um die häufigsten Probleme zu lösen. + Verbindungs-Tipps starten + Gescannt am: %s + Gescanntes Rezept + Entsperren + Karte gesperrt + Die PIN wurde dreimal falsch eingegeben. Ihre Karte wurde daher aus Sicherheitsgründen gesperrt. + Karte entsperren + PUK eingeben + Mit Ihrer PIN haben Sie eine 8-stellige PUK von Ihrer Versicherung erhalten. + Neue PIN wählen + Ihre neue persönliche Identifikationsnummer (PIN) können Sie selbst wählen (6 bis 8 Stellen). + PIN gemerkt? + Bitte notieren Sie sich Ihre PIN und bewahren an einem sicheren Ort auf. + Abbrechen + Falsche PUK eingegeben. + Okay + Entsperrung nicht möglich + Sie haben mit dieser PUK die maximale Anzahl an Karten-Entsperrungen erreicht oder haben sie wiederholt falsch eingegeben. Bitte wenden Sie sich an Ihre Versicherung. + Sie können eine PUK für bis zu 10 Entsperrvorgänge nutzen. + Karte entsperrt + Was Sie benötigen: + Ihre Gesundheitskarte + PUK Ihrer Gesundheitskarte + Weiter + Gesundheitskarte + Neue Karte bestellen + Anmelden + Rezepte online empfangen und an eine Apotheke weiterleiten. + NFC-fähige Gesundheitskarte + PIN zur Gesundheitskarte + Sie verfügen noch nicht über eine NFC-fähige Gesundheitskarte und PIN? + Jetzt bestellen + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204) + Oder: Melden Sie sich mit der %s an. + App Ihrer Krankenversicherung + "Ihre Zugangsnummer (Card Access Number, kurz: CAN) finden Sie in der rechten oberen Ecke Ihrer Gesundheitskarte." + Meine Karte verfügt über keine Zugangsnummer + + Sie haben noch %s weiteren Versuch, bevor Ihre Karte gesperrt wird. + Sie haben noch %s weitere Versuche, bevor Ihre Karte gesperrt wird. + + Gesundheitskarte an Rückseite des Telefons anlegen + Der folgende Prozess kann bis zu 30 Sekunden lang dauern. + Karte %s an der Rückseite des Telefons platzieren. + im oberen Bereich rechts + im oberen Bereich mittig + im oberen Bereich links + im mittleren Bereich rechts + mittig + im mittleren Bereich links + im unteren Bereich rechts + im unteren Bereich mittig + im unteren Bereich links + Hilfe + Gesendet vor %s Minuten + Gesendet am %s + Gesendet gerade eben + Gesendet um %s Uhr + Nicht mehr gültig + Mit App anmelden + Versicherung wählen + Nicht fündig geworden? Diese Liste wird ständig erweitert. Die Anmeldung mit Gesundheitskarte wird bereits jetzt von jeder Krankenversicherung unterstützt. + Feedback aus der E-Rezept App + Wir freuen uns auf Ihr Feedback. Bitte nutzen Sie den folgenden Platz und formulieren Sie so präzise wie möglich: + PUK + Schließen + Wie schade… + Leider erfüllt Ihr Gerät die Mindestanforderungen für die Anmeldung in der E-Rezept-App nicht. Für eine sichere Authentifizierung mit Ihrer Gesundheitskarte werden mindestens Android 7 sowie ein NFC-Chip benötigt. + Mehr erfahren + https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten + Zugangsdaten speichern? + Speichern + Nicht speichern + Hinweis + Aus Sicherheitsgründen wird die Verbindung zum Rezeptserver nach 12 Stunden getrennt. Für eine erneute Verbindung benötigen Sie für jeden Verbindungsvorgang Gesundheitskarte und PIN. + Biometrische Absicherung einrichten + Zugangsdaten speichern nicht möglich. Richten Sie zuvor auf Ihrem Gerät eine biometrische Absicherung (z.B. Fingerabruck ) ein. + Abbrechen + Einstellungen + Hinweis + Akzeptieren + Sicherheit Ihrer Rezeptdaten + \"Diese App verwendet den sichersten biometrischen Sensor, der von Ihrem Gerät zur Verfügung gestellt wird, um Ihre Zugangsdaten in einem geschützten Bereich des Gerätespeichers zu sichern. \" + Die biometrische Sicherung Ihrer Zugangsdaten erlaubt es, diese App in Zukunft ohne Gesundheitskarte und Eingabe der PIN zu öffnen, Rezepte einzusehen, abzurufen, einzulösen oder zu löschen. + Bitte achten Sie darauf, dass Personen, mit denen Sie gegebenenfalls dieses Gerät teilen und deren biometrische Merkmale auf diesem Gerät gespeichert sein könnten, ebenfalls Zugriff auf Ihre Rezepte erhalten. + Das hat leider nicht geklappt + Die Authentifizierung mit der Krankenversicherungs-App war nicht erfolgreich. + Abgelaufen am %s + Das Rezept wurde bereits vom Server gelöscht + Bitte Eingabe korrigieren oder Änderungen verwerfen + Korrigieren + Versichertendaten + Name + Versicherung + Versichertennummer + Zugangsnummer (CAN) + Anmelden + Abmelden + Um Rezepte automatisch zu erhalten, müssen Sie angemeldet sein. + Speichern + Ändern + Profilbild bearbeiten + Weiter + Server antwortet nicht + Bitte probieren Sie es zu einem späteren Zeitpunkt erneut. + Erneut versuchen + Nach Versicherung suchen + Jetzt mit dem Rezeptserver verbinden? + Erfolgreich angemeldet + Verbindung getrennt + Jetzt mit dem Rezeptserver verbinden? + Keine Tokens + Sie erhalten einen Token, wenn Sie am Rezeptdienst angemeldet sind.\n + Bestellungen + Bestellungen + https://t.maze.co/90489290 + Wunsch-PIN wählen + Karte entsperren + PIN wählen + PIN wiederholen + Die Eingaben weichen voneinander ab. + Keine Bestellungen + Sie haben noch keine Bestellungen. + Gerade eben + Um %s Uhr + Warenkorb steht bereit + Das Rezept wurde Ihrem Warenkorb hinzugefügt. Bitte wechseln Sie nun auf die Website der Apotheke, um die Bestellung abzuschließen. + Warenkorb öffnen + Zeigen Sie diesen Abholcode in der Apotheke vor. + Abholcode erhalten + Nachricht kann nicht angezeigt werden + Bitte kontaktieren Sie Ihre Apotheke (%s). + Warenkorb-Link anzeigen + Abholcode anzeigen + Nachricht anzeigen + %s um %s Uhr + Rezept an %s gesandt. + Bestellübersicht + Neu + Verlauf + Bestellung + Kostenfrei für den Anrufer. Servicezeiten: Mo - Fr 08:00 - 20:00 Uhr außer an bundeseinheitlichen Feiertagen + Apotheke + Wunsch-PIN wählen + Wunsch-PIN gespeichert + Speichern der Wunsch-PIN nicht möglich + E-Rezept + Aktuell geöffnet und in meiner Nähe + Filtern nach … + Suche starten + Abbrechen + Wird für Sie eingelöst + Direktzuweisung + Einlösen + Telefonnummer (optional) + Nach Name oder Adresse suchen + Keine gültigen Apothekeninformationen + Es wurden keine aktuellen Informationen über diese Apotheke gefunden. Der Eintrag zu dieser Apotheke wird gelöscht. + Okay + Apothekenverzeichnis nicht erreichbar + Derzeit können keine aktuellen Informationen über diese Apotheke abgerufen werden. Bitte überprüfen Sie Ihre Internetverbindung. + Abbrechen + Erneut versuchen + Select Environment + Save Environment + Anmeldung nicht möglich + Es scheint, als hätten sich Ihre Merkmale für die biometrische Anmeldung geändert. Bitte melden Sie sich erneut mit Ihrer Gesundheitskarte an. + Abbrechen + Anmelden + Profil 1 + In meiner Nähe + Sie haben keine einlösbaren Rezepte + Später einlösbar + Einlösbar ab %s + %s / %s + Produktverbesserungen + Anonyme Analyse + Helfen Sie uns, diese App besser zu machen. Alle Nutzerdaten werden anonym erhoben und dienen ausschließlich der Verbesserung des Nutzungserlebsnisses. + Gerätesicherheit + Persönliche Einstellungen + Bedienungshilfen + Produktverbesserungen + Rezept hinzugefügt + Rezept bereits vorhanden + Ein Fehler beim Importieren ist aufgetreten + CAN + Löschen + Gescanntes Rezept + Ersatzpräparat möglich + Gesendet am %s Uhr an %s + Eingelöst am %s Uhr bei %s + PIN vergessen + + %s Rezept + %s Rezepte + + Ich habe die Datenschutzerklärung und Nutzungsbestimmungen gelesen und akzeptiere sie. + Datenschutzerklärung + Nutzungsbestimmungen + Wir möchten: + Die Nutzbarkeit verbessern. + Fehler und Abstürze erkennen. + Alle Daten werden selbstverständlich anonym erhoben. + Sie können diese Entscheidung in den Systemeinstellungen jederzeit ändern. + Fortfahren + Akzeptieren + Diese App verwendet die sicherste Methode, die von Ihrem Gerät zur Verfügung gestellt wird. + Speichern + Wählen + Medikament + Handelsname + PZN + Ja + Nein + Dosierung + Ausstellungsdatum + Noctu + Dieses Rezept wird im Rahmen einer Behandlung für Sie eingelöst. + Keine Angabe + Keine Notdienstgebühr + Zuzahlung + Medikament + Abgabehinweise + Anspruchberechtigt nach BVG + Alternativpräparat + Rezepturname + Verpackung + Herstellungsanweisung + Beschreibung + ausgegeben von + ausgegeben am: + Wirkstoff + Verschrieben + Erhalten + Was ist eine Direktzuweisung? + Bei einer Direktzuweisungen wird ein Rezept von einer Praxis oder einem Krankenhaus direkt bei einer Apotheke eingelöst. Versicherte müssen hierbei nicht tätig werden und können nicht in den Einlösungsprozess eingreifen.\n\nDirektzuweisungen werden in der E-Rezept App aufgeführt, um Ihre Behandlung für Sie transparenter zu machen. + Keine Notdienstgebühr + Hier ist Eile geboten. Dieses Rezept kann ohne die zusätzliche Zahlung einer Notdienstgebühr auch nachts in einer Apotheke eingelöst werden. + Zuzahlungspflichtige Medikamente + Von der Zuzahlung befreit + Gesetzlich Versicherte müssen für verschreibungspflichtige Medikamente eine Zuzahlung von bis zu zehn Euro leisten.\n\nDie Höhe der Zuzahlung ist abhängig von dem Preis Ihres Medikaments. Medikamente, die weniger als 5€ kosten müssen Sie selbst zahlen.\nBei Medikamenten die teurer sind, müssen Sie zehn Prozent des Preises zuzahlen, mindestens aber 5€ und höchstens 10€.\n\nGrundsätzlich befreit von einer Zuzahlung sind Kinder und Jugendliche unter 18 Jahren. \n\nSollten Ihre jährlichen Kosten für Medikamente Ihre finanzielle Belastungsgrenzt überschreiten können Sie sich von der Zuzahlung befreien lassen. Sprechen Sie dazu mit Ihrer Krankenversicherung. + Sie sind von der Zuzahlung von diesem Medikament befreit. Die Kosten für das Medikament übernimmt Ihre Krankenkasse. + Wie lange ist dieses Rezept gültig? + Innerhalb dieses Zeitraums können Sie Ihr Rezept in einer beliebigen Apotheke mit einer Zuzahlung einlösen. + Innerhalb dieses Zeitraums können Sie das Rezept nach wie vor in einer Apotheke einlösen, müssen jedoch die Kaufsumme selbst übernehmen. Alternativ können Sie in Ihrer Praxis im eine erneute Ausstellung des Rezepts bitten. + Ersatzpräparat möglich + Aufgrund gesetzlicher Vorgaben Ihrer Krankenkasse kann Ihnen eine Alternative mit dem selben Wirkstoff ausgehändigt werden.\n\nMedikamente können unterschiedlich aussehen und heißen, andere Preise und Hersteller haben, aber dennoch den gleichen Wirkstoff beinhalten. Für die Wirkung von Arzneimitteln im Körper sind vor allem der Wirkstoff selbst und die Dosierung ausschlaggebend. Oft bekommen Patienten und Patientinnen in der Apotheke ein anderes Medikament als das vom Arzt oder der Ärztin auf dem Rezept verordnete – vorausgesetzt, die Medikamente sind vergleichbar. Für den Wechsel kann es therapeutische und wirtschaftliche Gründe geben. + Gescanntes Rezept + Von einem Papierausdruck importierte Rezepte dürfen aus Sicherheitsgründen keine persönlichen oder medizinische Daten anzeigen.\n\nMelden Sie sich in dieser App mit Gesundheitskarte oder Versicherungs-App an, um alle im Rezept enthaltenen Informationen einzusehen. + Rezept fehlerhaft + Dieses Rezept wurde fehlerhaft ausgestellt. + Gescanntes Rezept + Notdienstgebühr + Dosierung gemäß schriftlicher Anweisung + Telefon + Website + Mail + Sortierung nach Entfernung nicht möglich. + Okay + Aktuelle PIN eingeben + Falsche PIN eingegeben + Die aktuelle PIN Ihrer Gesundheitskarte + Karte gesperrt + Entsperren Sie Ihre Karte unter Einstellungen > Karte entsperren. + Bitte geben Sie aus Sicherheitsgründen Ihre aktuelle PIN ein. + PIN vergessen + Fehlerhaftes Rezept + Medikament + Bei der Erstellung Ihres Rezepts scheint etwas schief gelaufen zu sein. Fehler melden? + Melden + Nicht angemeldet + Angemeldet mit + Gesundheitskarte + Biometrie + Nicht angemeldet + Wir sind an Ihrer Meinung interessiert. Bitte nehmen Sie sich fünf Minuten Zeit, um unsere Umfrage zu beantworten. Haben Sie vielen Dank im Voraus. + Warnhinweis + Apotheke zu Favoriten hinzugefügt + Apotheke aus Favoriten entfernt + Meine Apotheken + Kennwortstärke sehr gut + Schreibvorgang nicht erfolgreich + PIN konnte nicht gespeichert werden + Melden + PIN vergeben + Zugriffsregel verletzt + Sie haben nicht die Berechtigung, auf das Kartenverzeichnis zuzugreifen. + Eigene Pin vergeben + Die Karte ist mit einer PIN Ihrer Krankenkasse (Transport-PIN) gesichert, bitte vergeben Sie eine eigene PIN. + Passwort nicht gefunden + Es ist kein Passwort auf Ihrer Karte hinterlegt. + Sie wurden abgemeldet + Melden Sie sich erneut an, um Ihre Rezepte zu aktualisieren. + Wirkstoffnummer + Wirkstärke und Einheit + Hier suchen + Kontakt und Öffnungszeiten + Einstellungen + Standort in den Einstellungen freigeben. + In meiner Nähe + Profilnamen ändern + Gedrückt halten, um den Namen zu bearbeiten. + Geben Sie den neuen Namen für das Profil ein. + Um Rezepte digital von Ihrer Praxis zu empfangen, müssen Sie angemeldet sein. + Rezepte digital empfangen? + Ziehen Sie den Screen nach unten, um zu aktualisieren. + Keine Rezepte + Fügen Sie Rezepte über den + Button in der rechten oberen Ecke hinzu. + Anmelden + Eingelöste Rezepte + Zuletzt aktualisiert gerade eben + Zuletzt aktualisiert vor %s Minuten + Zuletzt aktualisiert vor %s Tagen + Zuletzt aktualisiert am %s + Zuletzt aktualisiert um %s Uhr + Vielleicht später + Anmelden + Profilbild bearbeiten + Eingelöste Rezepte + Name eingeben + Speichern + diff --git a/android/src/main/res/values/strings_desktop.xml b/android/src/main/res/values/strings_desktop.xml index 21402472..501649f8 100644 --- a/android/src/main/res/values/strings_desktop.xml +++ b/android/src/main/res/values/strings_desktop.xml @@ -37,14 +37,14 @@ Legen Sie die Gesundheitskarte erneut auf das Lesegerät. - Nur noch heute als Selbstzahlender einlösbar - Noch %s Tag als Selbstzahlender einlösbar - Noch %s Tage als Selbstzahlender einlösbar + Nur noch heute als Selbstzahlender einlösbar + Noch %s Tag als Selbstzahlender einlösbar + Noch %s Tage als Selbstzahlender einlösbar - Nur noch heute gültig - Noch %s Tag gültig - Noch %s Tage gültig + Nur noch heute gültig + Noch %s Tag gültig + Noch %s Tage gültig Nicht mehr gültig Ausgestellt am %s diff --git a/android/src/main/res/values/strings_kbv_codes.xml b/android/src/main/res/values/strings_kbv_codes.xml index b17902e8..3c3ca58a 100644 --- a/android/src/main/res/values/strings_kbv_codes.xml +++ b/android/src/main/res/values/strings_kbv_codes.xml @@ -11,6 +11,8 @@ Nicht betroffen Sonstiges Ätherisches Öl + keine Darreichungsform + Digitale Gesundheitsanwendungen Ampullen Ampullenpaare Augen- und Nasensalbe diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/EndpointHelper.kt b/android/src/release/java/de/gematik/ti/erp/app/di/EndpointHelper.kt similarity index 77% rename from android/src/main/java/de/gematik/ti/erp/app/di/EndpointHelper.kt rename to android/src/release/java/de/gematik/ti/erp/app/di/EndpointHelper.kt index 63a759dc..acc9c9b4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/di/EndpointHelper.kt +++ b/android/src/release/java/de/gematik/ti/erp/app/di/EndpointHelper.kt @@ -21,17 +21,24 @@ package de.gematik.ti.erp.app.di import android.content.SharedPreferences import androidx.core.content.edit import de.gematik.ti.erp.app.BuildKonfig -import javax.inject.Inject -class EndpointHelper @Inject constructor( - @NetworkSharedPreferences +class EndpointHelper( private val networkPrefs: SharedPreferences ) { enum class EndpointUri(val original: String, val preferenceKey: String) { - BASE_SERVICE_URI(BuildKonfig.BASE_SERVICE_URI, "BASE_SERVICE_URI_OVERRIDE"), - IDP_SERVICE_URI(BuildKonfig.IDP_SERVICE_URI, "IDP_SERVICE_URI_OVERRIDE"), - PHARMACY_SERVICE_URI(BuildKonfig.PHARMACY_SERVICE_URI, "PHARMACY_BASE_URI") + BASE_SERVICE_URI( + BuildKonfig.BASE_SERVICE_URI, + "BASE_SERVICE_URI_OVERRIDE" + ), + IDP_SERVICE_URI( + BuildKonfig.IDP_SERVICE_URI, + "IDP_SERVICE_URI_OVERRIDE" + ), + PHARMACY_SERVICE_URI( + BuildKonfig.PHARMACY_SERVICE_URI, + "PHARMACY_BASE_URI_OVERRIDE" + ) } val eRezeptServiceUri @@ -71,4 +78,13 @@ class EndpointHelper @Inject constructor( putString(uri.preferenceKey, debugUri) } } + + fun getErpApiKey(): String = + BuildKonfig.ERP_API_KEY + + fun getPharmacyApiKey(): String = + BuildKonfig.PHARMACY_API_KEY + + fun getTrustAnchor(): String = + BuildKonfig.APP_TRUST_ANCHOR_BASE64 } diff --git a/android/src/release/java/de/gematik/ti/erp/app/utils/compose/ReleaseCommon.kt b/android/src/release/java/de/gematik/ti/erp/app/utils/compose/ReleaseCommon.kt index 148a9edc..ece37586 100644 --- a/android/src/release/java/de/gematik/ti/erp/app/utils/compose/ReleaseCommon.kt +++ b/android/src/release/java/de/gematik/ti/erp/app/utils/compose/ReleaseCommon.kt @@ -16,10 +16,13 @@ * */ +@file:Suppress("UnusedPrivateMember") + package de.gematik.ti.erp.app.utils.compose import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import de.gematik.ti.erp.app.MainActivity @Composable fun OutlinedDebugButton( @@ -29,3 +32,11 @@ fun OutlinedDebugButton( ) { error("Debug button should only be used in debug builds!") } + +fun Modifier.visualTestTag(tag: String) = + this + +@Composable +fun DebugOverlay(elements: Map) { + error("Debug overlay should only be used in debug builds!") +} diff --git a/android/src/sharedTest/java/de/gematik/ti/erp/app/messages/TestData.kt b/android/src/sharedTest/java/de/gematik/ti/erp/app/messages/TestData.kt deleted file mode 100644 index 6eb520f1..00000000 --- a/android/src/sharedTest/java/de/gematik/ti/erp/app/messages/TestData.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages - -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.attestation.SafetynetResult -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.db.entities.SafetynetAttestationEntity -import de.gematik.ti.erp.app.messages.ui.models.ErrorUIMessage -import de.gematik.ti.erp.app.messages.ui.models.UIMessage - -fun testUIMessage() = - UIMessage( - "communicationId", - "onPremise", - R.string.communication_shipment_inbox_header, - "this is a test message", - pickUpCodeHR = "hrPickup", - pickUpCodeDMC = "dmcPickup", - consumed = false - ) - -fun testErrorUIMessage() = - ErrorUIMessage( - "communicationId", - "none", - R.string.communication_error_inbox_header, - "the message that was sent", - R.string.communication_error_inbox_display_text, - "some time stamp", - R.string.communication_error_action_text, - false - ) - -fun communicationOnPremise() = - Communication( - "id", - CommunicationProfile.ErxCommunicationReply, - profileName = "Hans", - "time", - "taskId", - "telematiksId", - "kbvUserId", - "{\"version\": \"1\",\"supplyOptionsType\": \"onPremise\",\"info_text\": \"Wir möchten Sie informieren, dass Ihre bestellten Medikamente zur Abholung bereitstehen. Den Abholcode finden Sie anbei.\",\"pickUpCodeHR\": \"12341234\",\"pickUpCodeDMC\": \"465465465f6s4g6df54gs65dfg\",\"url\": \"\"}", - false - ) - -fun listOfCommunicationsRead() = - listOf(communicationOnPremise().copy(consumed = true)) - -fun listOfCommunicationsUnread() = - listOf(communicationOnPremise()) - -fun safetynetAttestationEntity() = - SafetynetAttestationEntity(id = 0, jws = "", ourNonce = "".toByteArray()) - -fun listOfAttestationEntities() = - listOf(safetynetAttestationEntity()) - -fun safetynetResult() = SafetynetResult("") diff --git a/android/src/sharedTest/java/de/gematik/ti/erp/app/utils/CoroutineTestRule.kt b/android/src/test/java/de/gematik/ti/erp/app/CoroutineTestRule.kt similarity index 67% rename from android/src/sharedTest/java/de/gematik/ti/erp/app/utils/CoroutineTestRule.kt rename to android/src/test/java/de/gematik/ti/erp/app/CoroutineTestRule.kt index f484fb59..3670d08c 100644 --- a/android/src/sharedTest/java/de/gematik/ti/erp/app/utils/CoroutineTestRule.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/CoroutineTestRule.kt @@ -16,28 +16,29 @@ * */ -package de.gematik.ti.erp.app.utils +package de.gematik.ti.erp.app -import de.gematik.ti.erp.app.DispatchProvider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) class CoroutineTestRule( - val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() + val testDispatcher: TestDispatcher = StandardTestDispatcher() ) : TestWatcher() { - val testDispatchProvider = object : DispatchProvider { - override fun default(): CoroutineDispatcher = testDispatcher - override fun io(): CoroutineDispatcher = testDispatcher - override fun main(): CoroutineDispatcher = testDispatcher - override fun unconfined(): CoroutineDispatcher = testDispatcher + val dispatchers = object : DispatchProvider { + override val Default: CoroutineDispatcher get() = testDispatcher + override val IO: CoroutineDispatcher get() = testDispatcher + override val Main: CoroutineDispatcher get() = testDispatcher + override val Unconfined: CoroutineDispatcher get() = testDispatcher } override fun starting(description: Description?) { @@ -48,6 +49,6 @@ class CoroutineTestRule( override fun finished(description: Description?) { super.finished(description) Dispatchers.resetMain() - testDispatcher.cleanupTestCoroutines() + testDispatcher.cancel() } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCaseTest.kt index d4ba39b5..6f8c7cc2 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCaseTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/attestation/usecase/SafetynetUseCaseTest.kt @@ -18,29 +18,28 @@ package de.gematik.ti.erp.app.attestation.usecase +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.attestation.AttestationException import de.gematik.ti.erp.app.attestation.AttestationReportGenerator import de.gematik.ti.erp.app.attestation.SafetynetReport +import de.gematik.ti.erp.app.attestation.SafetynetResult +import de.gematik.ti.erp.app.attestation.model.AttestationData import de.gematik.ti.erp.app.attestation.repository.SafetynetAttestationRepository -import de.gematik.ti.erp.app.messages.listOfAttestationEntities -import de.gematik.ti.erp.app.messages.safetynetResult -import de.gematik.ti.erp.app.utils.CoroutineTestRule import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import junit.framework.Assert.assertFalse -import junit.framework.Assert.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.test.assertEquals @ExperimentalCoroutinesApi class SafetynetUseCaseTest { + private val attestation = AttestationData.SafetynetAttestation("", byteArrayOf()) private lateinit var useCase: SafetynetUseCase private lateinit var repo: SafetynetAttestationRepository @@ -57,27 +56,26 @@ class SafetynetUseCaseTest { reportGenerator = mockk() attestationReport = mockk() every { attestationReport.timestampMS } returns now - useCase = SafetynetUseCase(repo, reportGenerator, coroutineRule.testDispatchProvider) + useCase = SafetynetUseCase(repo, reportGenerator, coroutineRule.dispatchers) } @Test - fun `test running safetynet attestation - throws AttestationException`() { - coroutineRule.testDispatcher.runBlockingTest { - every { repo.fetchAttestationsLocal() } returns flowOf(listOfAttestationEntities()) + fun `test running safetynet attestation - throws AttestationException`() = + runTest { + every { repo.fetchAttestationsLocal() } returns flowOf(attestation) coEvery { reportGenerator.convertToReport(any(), any()) } returns attestationReport every { attestationReport.attestationCheckOK(any()) } throws AttestationException( AttestationException.AttestationExceptionType.ATTESTATION_FAILED, message = "fail" ) val result = useCase.runSafetynetAttestation().first() - assertFalse(result) + assertEquals(false, result) } - } @Test fun `test running safetynet attestation - throws Exception when creating report`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repo.fetchAttestationsLocal() } returns flow { emit(listOfAttestationEntities()) } + runTest { + every { repo.fetchAttestationsLocal() } returns flowOf(attestation) coEvery { repo.fetchAttestationReportRemote(any()) } returns safetynetResult() coEvery { @@ -91,29 +89,31 @@ class SafetynetUseCaseTest { ) every { attestationReport.attestationCheckOK(any()) } val result = useCase.runSafetynetAttestation().first() - assertFalse(result) + assertEquals(false, result) } @Test fun `test running safetynet attestation - throws Exception when fetching safetynet from remote`() { - coroutineRule.testDispatcher.runBlockingTest { - every { repo.fetchAttestationsLocal() } returns flow { emit(listOfAttestationEntities()) } + runTest { + every { repo.fetchAttestationsLocal() } returns flowOf(attestation) coEvery { repo.fetchAttestationReportRemote(any()) } throws Exception("failed fetching safetynet") coEvery { reportGenerator.convertToReport(any(), any()) } returns attestationReport every { attestationReport.attestationCheckOK(any()) } returns Unit val result = useCase.runSafetynetAttestation().first() - assertTrue(result) + assertEquals(true, result) } } @Test fun `test running safetynet attestation - passes`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repo.fetchAttestationsLocal() } returns flow { emit(listOfAttestationEntities()) } + runTest { + every { repo.fetchAttestationsLocal() } returns flowOf(attestation) coEvery { reportGenerator.convertToReport(any(), any()) } returns attestationReport every { attestationReport.attestationCheckOK(any()) } returns Unit val result = useCase.runSafetynetAttestation().first() - assertTrue(result) + assertEquals(true, result) } } + +fun safetynetResult() = SafetynetResult("") diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModelTest.kt deleted file mode 100644 index 48688faf..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/ui/CardWallViewModelTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -// package de.gematik.ti.erp.app.cardwall.ui -// -// import androidx.arch.core.executor.testing.InstantTaskExecutorRule -// import de.gematik.ti.erp.app.cardwall.usecase.CardWallUseCase -// import de.gematik.ti.erp.app.di.AppSharedPreferences -// import de.gematik.ti.erp.app.di.SecureCardWallSharedPreferences -// import de.gematik.ti.erp.app.utils.CoroutineTestRule -// import de.gematik.ti.erp.app.utils.getOrAwaitValue -// import io.mockk.mockk -// import io.mockk.verify -// import kotlinx.coroutines.ExperimentalCoroutinesApi -// import org.junit.Assert.assertEquals -// import org.junit.Before -// import org.junit.Rule -// import org.junit.Test -// -// @ExperimentalCoroutinesApi -// class CardWallViewModelTest { -// -// private lateinit var viewModel: CardWallViewModel -// private lateinit var appPrefs: AppSharedPreferences -// private lateinit var secPrefs: SecureCardWallSharedPreferences -// private lateinit var cardWallUseCase: CardWallUseCase -// -// @get:Rule -// val instantTaskExecutorRule = InstantTaskExecutorRule() -// -// @get:Rule -// val coroutineRule = CoroutineTestRule() -// -// @Before -// fun setup() { -// appPrefs = mockk(relaxed = true) -// secPrefs = mockk(relaxed = true) -// cardWallUseCase = mockk(relaxed = true) -// -// viewModel = CardWallViewModel(cardWallUseCase) -// } -// -// @Test -// fun `reload view model`() { -// viewModel.reload() -// -// verify { -// cardWallUseCase.getStoredCan() -// } -// -// val can = viewModel.cardAccessNumber.getOrAwaitValue() -// assertEquals(can, "") -// } -// -// @Test -// fun `pin check`() { -// assertEquals(false, viewModel.checkPersonalIdentificationNumber("123")) -// assertEquals(true, viewModel.checkPersonalIdentificationNumber("123456")) -// assertEquals(true, viewModel.checkPersonalIdentificationNumber("1234567")) -// assertEquals(true, viewModel.checkPersonalIdentificationNumber("12345678")) -// assertEquals(false, viewModel.checkPersonalIdentificationNumber("123456789")) -// } -// -// @Test -// fun `can check`() { -// assertEquals(false, viewModel.checkPersonalIdentificationNumber("123")) -// assertEquals(true, viewModel.checkPersonalIdentificationNumber("123456")) -// assertEquals(false, viewModel.checkPersonalIdentificationNumber("123456789")) -// } -// } diff --git a/android/src/test/java/de/gematik/ti/erp/app/core/MainViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/core/MainViewModelTest.kt index 0a1284cd..1b51c3b6 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/core/MainViewModelTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/core/MainViewModelTest.kt @@ -18,12 +18,12 @@ package de.gematik.ti.erp.app.core +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.attestation.usecase.SafetynetUseCase -import de.gematik.ti.erp.app.idp.usecase.IdpUseCase -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.settings.model.SettingsData.AppVersion +import de.gematik.ti.erp.app.settings.model.SettingsData.AuthenticationMode import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK @@ -31,11 +31,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test +import java.time.Instant @OptIn(ExperimentalCoroutinesApi::class) class MainViewModelTest { @@ -47,15 +48,6 @@ class MainViewModelTest { @MockK private lateinit var settingsUseCase: SettingsUseCase - @MockK - private lateinit var prescriptionUseCase: PrescriptionUseCase - - @MockK - private lateinit var profilesUseCase: ProfilesUseCase - - @MockK(relaxed = true) - private lateinit var idpUseCase: IdpUseCase - @MockK private lateinit var safetynetUseCase: SafetynetUseCase @@ -63,53 +55,66 @@ class MainViewModelTest { fun setup() { MockKAnnotations.init(this) every { safetynetUseCase.runSafetynetAttestation() } returns flow { emit(true) } + every { settingsUseCase.general } returns flowOf( + SettingsData.General( + latestAppVersion = AppVersion(code = 1, name = "Test"), + onboardingShownIn = null, + dataProtectionVersionAcceptedOn = Instant.now(), + zoomEnabled = false, + userHasAcceptedInsecureDevice = false, + authenticationFails = 0, + welcomeDrawerShown = false + ) + ) + every { settingsUseCase.authenticationMode } returns flowOf(AuthenticationMode.Unspecified) } @Test - fun `test showInsecureDevicePrompt - only show once`() = coroutineRule.testDispatcher.runBlockingTest { + fun `test showInsecureDevicePrompt - only show once`() = runTest { every { settingsUseCase.showDataTermsUpdate } returns flowOf(false) every { settingsUseCase.showInsecureDevicePrompt } returns flowOf(true) - every { settingsUseCase.isNewUser } returns false - every { profilesUseCase.isProfileSetupCompleted() } returns flowOf(true) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + every { settingsUseCase.showOnboarding } returns flowOf(false) + every { settingsUseCase.showWelcomeDrawer } returns flowOf(false) + + viewModel = MainViewModel(safetynetUseCase, settingsUseCase) assertEquals(true, viewModel.showInsecureDevicePrompt.first()) assertEquals(false, viewModel.showInsecureDevicePrompt.first()) } @Test - fun `test showInsecureDevicePrompt - device is secure`() = coroutineRule.testDispatcher.runBlockingTest { + fun `test showInsecureDevicePrompt - device is secure`() = runTest { every { settingsUseCase.showDataTermsUpdate } returns flowOf(false) every { settingsUseCase.showInsecureDevicePrompt } returns flowOf(false) - every { settingsUseCase.isNewUser } returns false - every { profilesUseCase.isProfileSetupCompleted() } returns flowOf(true) + every { settingsUseCase.showOnboarding } returns flowOf(false) + every { settingsUseCase.showWelcomeDrawer } returns flowOf(false) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + viewModel = MainViewModel(safetynetUseCase, settingsUseCase) assertEquals(false, viewModel.showInsecureDevicePrompt.first()) assertEquals(false, viewModel.showInsecureDevicePrompt.first()) } @Test - fun `test showDataTermsUpdate - dataTerms updates should be shown`() = coroutineRule.testDispatcher.runBlockingTest { + fun `test showDataTermsUpdate - dataTerms updates should be shown`() = runTest { every { settingsUseCase.showDataTermsUpdate } returns flowOf(true) every { settingsUseCase.showInsecureDevicePrompt } returns flowOf(false) - every { settingsUseCase.isNewUser } returns false - every { profilesUseCase.isProfileSetupCompleted() } returns flowOf(true) + every { settingsUseCase.showOnboarding } returns flowOf(false) + every { settingsUseCase.showWelcomeDrawer } returns flowOf(false) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + viewModel = MainViewModel(safetynetUseCase, settingsUseCase) assertEquals(true, viewModel.showDataTermsUpdate.first()) } @Test - fun `test showDataTermsUpdate - dataTerms updates should not be shown`() = coroutineRule.testDispatcher.runBlockingTest { + fun `test showDataTermsUpdate - dataTerms updates should not be shown`() = runTest { every { settingsUseCase.showDataTermsUpdate } returns flowOf(false) every { settingsUseCase.showInsecureDevicePrompt } returns flowOf(false) - every { settingsUseCase.isNewUser } returns false - every { profilesUseCase.isProfileSetupCompleted() } returns flowOf(true) + every { settingsUseCase.showOnboarding } returns flowOf(false) + every { settingsUseCase.showWelcomeDrawer } returns flowOf(false) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + viewModel = MainViewModel(safetynetUseCase, settingsUseCase) assertEquals(false, viewModel.showDataTermsUpdate.first()) } diff --git a/android/src/test/java/de/gematik/ti/erp/app/di/SecureCardWallSharedPreferencesTest.kt b/android/src/test/java/de/gematik/ti/erp/app/di/SecureCardWallSharedPreferencesTest.kt deleted file mode 100644 index 1b24dec6..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/di/SecureCardWallSharedPreferencesTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.di - -import android.content.SharedPreferences -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import io.mockk.mockk -import org.junit.Before - -class SecureCardWallSharedPreferencesTest { - - private lateinit var appPrefs: SecureCardWallSharedPreferences - private lateinit var appNormalPrefs: SharedPreferences - private lateinit var appDemoPrefs: SharedPreferences - private lateinit var demoUseCase: DemoUseCase - - @Before - fun setup() { - appNormalPrefs = mockk(relaxed = true) - appDemoPrefs = mockk(relaxed = true) - demoUseCase = mockk(relaxed = true) - - appPrefs = SecureCardWallSharedPreferences(appNormalPrefs, appDemoPrefs, demoUseCase) - } -// -// @Test -// fun `expose normal preferences`() { -// every { demoMode.isDemoModeActive } answers { false } -// -// assertEquals(appNormalPrefs, appPrefs()) -// } -// -// @Test -// fun `expose demo preferences`() { -// every { demoMode.isDemoModeActive } answers { true } -// -// assertEquals(appDemoPrefs, appPrefs()) -// } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/messages/ui/MessageViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/messages/ui/MessageViewModelTest.kt deleted file mode 100644 index 7ab74c21..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/messages/ui/MessageViewModelTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.ui - -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.messages.listOfCommunicationsRead -import de.gematik.ti.erp.app.messages.usecase.MessageUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import io.mockk.every -import io.mockk.mockk -import junit.framework.Assert.assertTrue -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class MessageViewModelTest { - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private lateinit var viewModel: MessageViewModel - private lateinit var useCase: MessageUseCase - - @Before - fun setUp() { - useCase = mockk() - viewModel = MessageViewModel(useCase, coroutineRule.testDispatchProvider) - } - - @Test - fun `test loading communications - not empty list`() = - coroutineRule.testDispatcher.runBlockingTest { - every { useCase.loadCommunicationsLocally(any()) } returns flow { listOfCommunicationsRead() } - viewModel.fetchCommunications().collect { - assertTrue(it.isNotEmpty()) - } - } - - @Test - fun `test loading communications - empty list`() = - coroutineRule.testDispatcher.runBlockingTest { - every { useCase.loadCommunicationsLocally(any()) } returns flow { listOf() } - viewModel.fetchCommunications().collect { - assertTrue(it.isEmpty()) - } - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/messages/usecase/MessageUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/messages/usecase/MessageUseCaseTest.kt deleted file mode 100644 index 422a3b0e..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/messages/usecase/MessageUseCaseTest.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.messages.usecase - -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.messages.communicationOnPremise -import de.gematik.ti.erp.app.messages.listOfCommunicationsUnread -import de.gematik.ti.erp.app.messages.repository.MessageRepository -import de.gematik.ti.erp.app.messages.ui.models.ErrorUIMessage -import de.gematik.ti.erp.app.messages.ui.models.UIMessage -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.communicationDelivery -import de.gematik.ti.erp.app.utils.communicationShipment -import de.gematik.ti.erp.app.utils.errorCommunicationDelivery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -private const val SHIPMENT = "shipment" -private const val ON_PREMISE = "onPremise" -private const val DELIVERY = "delivery" -private const val ERROR = "none" - -@ExperimentalCoroutinesApi -class MessageUseCaseTest { - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private lateinit var useCase: MessageUseCase - private lateinit var repository: MessageRepository - private lateinit var profileUseCase: ProfilesUseCase - private lateinit var moshi: Moshi - - @Before - fun setUp() { - repository = mockk() - profileUseCase = mockk() - moshi = Moshi.Builder().build() - useCase = MessageUseCase(repository, profileUseCase, moshi) - every { profileUseCase.activeProfileName() } returns flow { emit("Tester") } - } - - @Test - fun `test loading communications - should return non empty list`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit(listOfCommunicationsUnread()) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .toList() - assertTrue(result.isNotEmpty()) - } - - @Test - fun `test unread communications available - should return true`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadUnreadCommunications(any(), any()) } returns flow { - emit(listOfCommunicationsUnread()) - } - val result = - useCase.unreadCommunicationsAvailable(CommunicationProfile.ErxCommunicationReply) - .first() - assertTrue(result) - } - - @Test - fun `test unread communications available - should return false`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadUnreadCommunications(any(), any()) } returns flow { - emit(listOf()) - } - val result = - useCase.unreadCommunicationsAvailable(CommunicationProfile.ErxCommunicationReply) - .first() - assertFalse(result) - } - - @Test - fun `test loading communications - should map to Shipment`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit( - listOf(communicationShipment()) - ) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .first() - val uiMessage = result.first() - assertNotNull(uiMessage) - assertTrue(uiMessage is UIMessage) - assertTrue(uiMessage.supplyOptionsType == SHIPMENT) - } - - @Test - fun `test loading communications - should map to Delivery`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit(listOf(communicationDelivery())) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .first() - val uiMessage = result.first() - assertNotNull(uiMessage) - assertTrue(uiMessage is UIMessage) - assertTrue(uiMessage.supplyOptionsType == DELIVERY) - } - - @Test - fun `test loading communications - should map to OnPremise`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit(listOf(communicationOnPremise())) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .first() - val uiMessage = result.first() - assertNotNull(uiMessage) - assertTrue(uiMessage is UIMessage) - assertTrue(uiMessage.supplyOptionsType == ON_PREMISE) - } - - @Test - fun `test mapping communications - should map to OnPremise`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit(listOf(communicationOnPremise())) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .first() - val uiMessage = result.first() as UIMessage - assertNotNull(uiMessage) - assertTrue(uiMessage.supplyOptionsType == ON_PREMISE) - assertFalse(uiMessage.consumed) - assertEquals(uiMessage.communicationId, "id") - assertEquals(uiMessage.pickUpCodeDMC, "465465465f6s4g6df54gs65dfg") - assertEquals(uiMessage.pickUpCodeHR, "12341234") - assertFalse(uiMessage.message.isNullOrEmpty()) - } - - @Test - fun `test mapping communications - null message`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit(listOf(communicationDelivery())) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .first() - val uiMessage = result.first() - assertNotNull(uiMessage) - assertTrue(uiMessage is UIMessage) - assertTrue(uiMessage.supplyOptionsType == DELIVERY) - assertFalse(uiMessage.consumed) - assertTrue(uiMessage.message.isNullOrEmpty()) - } - - @Test - fun `test mapping communications - should map to Error`() = - coroutineRule.testDispatcher.runBlockingTest { - every { repository.loadCommunications(any(), any()) } returns flow { - emit(listOf(errorCommunicationDelivery())) - } - val result = - useCase.loadCommunicationsLocally(CommunicationProfile.ErxCommunicationReply) - .first() - val message = result.first() - assertNotNull(message) - assertTrue(message is ErrorUIMessage) - assertTrue(message.supplyOptionsType == ERROR) - assertFalse(message.consumed) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCaseTest.kt index efbc9e22..75a34db6 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCaseTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/orderhealthcard/usecase/HealthCardOrderUseCaseTest.kt @@ -22,27 +22,56 @@ import org.junit.Test import kotlin.test.assertEquals private val contacts = """ - Kassenname;eGK und PIN Rufnummer;eGK und PIN eMail;eGK und PIN URL;PIN URL - Krankenkasse A;001422345336789;kk@example.com;https://www.krankenkasse.kk/; - Krankenkasse B;;kk@example.com;https://www.krankenkasse.kk/; + [ + { + "name":"Kasse 1", + "healthCardAndPinPhone":"+123123", + "healthCardAndPinMail":"TestMail@test.de", + "healthCardAndPinUrl":"https://www.TestURL.de/", + "pinUrl":"https://www.TestPinURL.de/", + "subjectCardAndPinMail":"testHeader", + "bodyCardAndPinMail":"testBody", + "subjectPinMail":"testHeader", + "bodyPinMail":"testBody" + }, + { + "name":"Kasse 2", + "healthCardAndPinPhone":null, + "healthCardAndPinMail":null, + "healthCardAndPinUrl":null, + "pinUrl":null, + "subjectCardAndPinMail":null, + "bodyCardAndPinMail":null, + "subjectPinMail":null, + "bodyPinMail":null + } + ] """.trimIndent() class HealthCardOrderUseCaseTest { @Test fun `test loadHealthInsuranceContactsFromCSV() with expected data`() { - loadHealthInsuranceContactsFromCSV(contacts.byteInputStream()).let { - assertEquals("Krankenkasse A", it[0].name) - assertEquals("kk@example.com", it[0].healthCardAndPinMail) - assertEquals("001422345336789", it[0].healthCardAndPinPhone) - assertEquals("https://www.krankenkasse.kk/", it[0].healthCardAndPinUrl) - assertEquals(null, it[0].pinUrl) + loadHealthInsuranceContactsFromJSON(contacts.byteInputStream()).let { + assertEquals("Kasse 1", it[0].name) + assertEquals("TestMail@test.de", it[0].healthCardAndPinMail) + assertEquals("+123123", it[0].healthCardAndPinPhone) + assertEquals("https://www.TestURL.de/", it[0].healthCardAndPinUrl) + assertEquals("https://www.TestPinURL.de/", it[0].pinUrl) + assertEquals("testHeader", it[0].subjectCardAndPinMail) + assertEquals("testBody", it[0].bodyCardAndPinMail) + assertEquals("testHeader", it[0].subjectPinMail) + assertEquals("testBody", it[0].bodyPinMail) - assertEquals("Krankenkasse B", it[1].name) - assertEquals("kk@example.com", it[1].healthCardAndPinMail) + assertEquals("Kasse 2", it[1].name) + assertEquals(null, it[1].healthCardAndPinMail) assertEquals(null, it[1].healthCardAndPinPhone) - assertEquals("https://www.krankenkasse.kk/", it[1].healthCardAndPinUrl) + assertEquals(null, it[1].healthCardAndPinUrl) assertEquals(null, it[1].pinUrl) + assertEquals(null, it[1].subjectCardAndPinMail) + assertEquals(null, it[1].bodyCardAndPinMail) + assertEquals(null, it[1].subjectPinMail) + assertEquals(null, it[1].bodyPinMail) } } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/orders/usecase/OrderUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/orders/usecase/OrderUseCaseTest.kt new file mode 100644 index 00000000..00a32d4c --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/orders/usecase/OrderUseCaseTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.orders.usecase + +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.orders.usecase.model.OrderUseCaseData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import org.junit.Rule +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +class OrderUseCaseTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + @Test + fun `communication to message - normal`() { + val communication = SyncedTaskData.Communication( + taskId = "", + orderId = "", + communicationId = "CID123456", + profile = SyncedTaskData.CommunicationProfile.ErxCommunicationReply, + sentOn = Instant.ofEpochMilli(123456), + sender = "ABC123456", + recipient = "ABC654321", + payload = """ + { + "version": 1, + "info_text": "Hi!", + "supplyOptionsType": "shipment", + "url": "https://example.org" + } + """.trimIndent(), + consumed = false + ) + val expected = OrderUseCaseData.Message( + communicationId = "CID123456", + sentOn = Instant.ofEpochMilli(123456), + message = "Hi!", + code = null, + link = "https://example.org", + consumed = false + ) + assertEquals(expected, communication.toMessage()) + } + + @Test + fun `communication to message - payload partially empty`() { + val communication = SyncedTaskData.Communication( + taskId = "", + orderId = "", + communicationId = "CID123456", + profile = SyncedTaskData.CommunicationProfile.ErxCommunicationReply, + sentOn = Instant.ofEpochMilli(123456), + sender = "ABC123456", + recipient = "ABC654321", + payload = """{ "version": 1, "supplyOptionsType": "shipment", "url": " ", "pickUpCodeHR": "" }""", + consumed = false + ) + val expected = OrderUseCaseData.Message( + communicationId = "CID123456", + sentOn = Instant.ofEpochMilli(123456), + message = null, + code = null, + link = null, + consumed = false + ) + assertEquals(expected, communication.toMessage()) + } + + @Test + fun `communication to message - payload broken`() { + val communication = SyncedTaskData.Communication( + taskId = "", + orderId = "", + communicationId = "CID123456", + profile = SyncedTaskData.CommunicationProfile.ErxCommunicationReply, + sentOn = Instant.ofEpochMilli(123456), + sender = "ABC123456", + recipient = "ABC654321", + payload = """{ - """, + consumed = false + ) + val expected = OrderUseCaseData.Message( + communicationId = "CID123456", + sentOn = Instant.ofEpochMilli(123456), + message = null, + code = null, + link = null, + consumed = false + ) + assertEquals(expected, communication.toMessage()) + } + + @Test + fun `communication to message - invalid url`() { + val communication = SyncedTaskData.Communication( + taskId = "", + orderId = "", + communicationId = "CID123456", + profile = SyncedTaskData.CommunicationProfile.ErxCommunicationReply, + sentOn = Instant.ofEpochMilli(123456), + sender = "ABC123456", + recipient = "ABC654321", + payload = """{ "version": 1, "supplyOptionsType": "shipment", "url": "ftp://example.org" }""", + consumed = false + ) + val expected = OrderUseCaseData.Message( + communicationId = "CID123456", + sentOn = Instant.ofEpochMilli(123456), + message = null, + code = null, + link = null, + consumed = false + ) + assertEquals(expected, communication.toMessage()) + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/PharmacyDirectCommunicationTest.kt b/android/src/test/java/de/gematik/ti/erp/app/pharmacy/PharmacyDirectCommunicationTest.kt new file mode 100644 index 00000000..6bc2a207 --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/pharmacy/PharmacyDirectCommunicationTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.pharmacy + +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.util.encoders.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals + +private val testCert = """ + MIIFUTCCBDmgAwIBAgIDQNF0MA0GCSqGSIb3DQEBCwUAMIGJMQswCQYDVQQGEwJE + RTEVMBMGA1UECgwMRC1UUlVTVCBHbWJIMUgwRgYDVQQLDD9JbnN0aXR1dGlvbiBk + ZXMgR2VzdW5kaGVpdHN3ZXNlbnMtQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0 + dXIxGTAXBgNVBAMMEEQtVHJ1c3QuU01DQi1DQTMwHhcNMjExMDExMDM0ODU0WhcN + MjYwODE1MDcyOTMxWjBmMQswCQYDVQQGEwJERTEgMB4GA1UECgwXQmV0cmllYnNz + dMOkdHRlIGdlbWF0aWsxIDAeBgNVBAUTFzEwLjgwMjc2MDAzMTExMDAwMDAwNTQy + MRMwEQYDVQQDDApnZW1hdGlrMDA2MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEAmtDDCfvOJL82smWeqCKa1azV3SpMHOhO2P+ot6Yi+DRqANl/0HyUO+b5 + VGatK1ugqONe9f0jfwUCPKxr33V5dmtJ4F4Ywbjv5rfYhMdTR1XMbrzoOwAFhdve + 0k42dXbW2NCr8TZLz7xlcKihRphuzGbnGa+XpJriaw7g6fNmdo27Ad4tNIpezqFQ + WduRJMDnW+89bzOdicLmyKU2k6IK9Wpd8+TjQLtoG32IAxX/+auqf9wYZW9H7mGF + BagPxLO7D8cWaaX0K3JtRfCCE2hS7iBd6EqGCeoGz9NFg6aMDLxSOTuEgriTOI/O + WSXVpFyAp9amm6KUmdhKegQ0iSvS0wIDAQABo4IB4jCCAd4wHwYDVR0jBBgwFoAU + xk6YSKNeL3M/yJih5vVHqDXIhTowcgYFKyQIAwMEaTBnpCYwJDELMAkGA1UEBhMC + REUxFTATBgNVBAoMDGdlbWF0aWsgR21iSDA9MDswOTA3MBkMF0JldHJpZWJzc3TD + pHR0ZSBnZW1hdGlrMAkGByqCFABMBDoTDzktMi41OC4wMDAwMDA0MDBEBggrBgEF + BQcBAQQ4MDYwNAYIKwYBBQUHMAGGKGh0dHA6Ly9ELVRSVVNULVNNQ0ItQ0EzLm9j + c3AuZC10cnVzdC5uZXQwUQYDVR0gBEowSDA7BggqghQATASBIzAvMC0GCCsGAQUF + BwIBFiFodHRwOi8vd3d3LmdlbWF0aWsuZGUvZ28vcG9saWNpZXMwCQYHKoIUAEwE + TDBxBgNVHR8EajBoMGagZKBihmBsZGFwOi8vZGlyZWN0b3J5LmQtdHJ1c3QubmV0 + L0NOPUQtVHJ1c3QuU01DQi1DQTMsTz1ELVRSVVNUJTIwR21iSCxDPURFP2NlcnRp + ZmljYXRlcmV2b2NhdGlvbmxpc3QwHQYDVR0OBBYEFO4u6BXelEMIzPzPE3Dr+mYU + Eto/MA4GA1UdDwEB/wQEAwIEMDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUA + A4IBAQDVUgAkYpXjjeUJbj2fWEXcgiFC0xEk0yAwmY9jK6An0fT+cRC/quTdZx81 + BR0qt77ROBJ3Sw5CH5+Ai4bjfIsmPOtIFV3qlYWgkldXhUfNHO+pLtdSnlhr7q4M + pAoX8pyHrLyMPubJwBSeEHoY6yrW8bm1Pmo3NY/haOGEtuu6oS4hOqUD7kGHFsVp + xYQY3gSzVzSv8B2d/pQ6zt6PU2nAYPV+JmRGBXGKPL8ncvZuQK0UsuMpNW0Q7sP6 + YDxLibjz3631dSjPs5MxIinKVxRPPSm357w8ekTs89oWshDGMuY8Oz7pu4taFHpE + 3xlzYXhnic0Bj61g6O9YFjcL43No +""".trimIndent().replace("\n", "") + +class PharmacyDirectCommunicationTest { + @Test + fun `filter by RSA algorithm`() { + assertEquals(1, listOf(X509CertificateHolder(Base64.decode(testCert))).filterByRSAPublicKey().size) + } + + @Test + fun `extract telematik id`() { + val cert = X509CertificateHolder(Base64.decode(testCert)) + + assertEquals("9-2.58.00000040", cert.extractTelematikId()) + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyMapperTest.kt b/android/src/test/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyMapperTest.kt deleted file mode 100644 index 4ec08fb7..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyMapperTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.repository - -import de.gematik.ti.erp.app.pharmacy.repository.model.DeliveryPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.EmergencyPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.LocalPharmacyService -import de.gematik.ti.erp.app.pharmacy.repository.model.Location -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningHours -import de.gematik.ti.erp.app.pharmacy.repository.model.OpeningTime -import de.gematik.ti.erp.app.utils.testPharmacySearchBundle -import org.hl7.fhir.r4.model.Bundle -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.time.DayOfWeek -import java.time.LocalTime -import java.time.OffsetDateTime -import java.time.ZoneOffset - -private const val LOCATION_NAME = "Heide-Apotheke" -private const val ADDRESS_LINE = "Langener Landstraße 266" -private const val ADDRESS_CITY = "Bremerhaven" -private const val ADDRESS_POSTAL_CODE = "27578" -private const val TELEMATIK_ID = "3-05.2.1007600000.080" - -class PharmacyMapperTest { - - lateinit var bundle: Bundle - - @Before - fun setup() { - bundle = testPharmacySearchBundle() - } - - @Test - fun `extract pharmacy one with 3 services`() { - val (pharmacies, _, _) = PharmacyMapper.extractLocalPharmacyServices(bundle) - assertEquals(10, pharmacies.size) - - val services = pharmacies[0].provides - assertEquals(3, services.size) - - assertTrue(services[0] is LocalPharmacyService) - assertTrue(services[1] is DeliveryPharmacyService) - assertTrue(services[2] is EmergencyPharmacyService) - - val openingTime = OffsetDateTime.of(2021, 4, 20, 9, 20, 0, 0, ZoneOffset.of("+2")) - val sunday = OffsetDateTime.of(2021, 4, 25, 9, 20, 0, 0, ZoneOffset.of("+2")) - val saturdayEvening = OffsetDateTime.of(2021, 4, 24, 18, 20, 0, 0, ZoneOffset.of("+2")) - - assertTrue(services[0].isOpenAt(openingTime)) - assertFalse(services[0].isOpenAt(sunday)) - assertTrue(services[1].isOpenAt(openingTime)) - assertFalse(services[1].isOpenAt(sunday)) - assertTrue(services[2].isOpenAt(sunday)) - assertTrue(services[2].isOpenAt(saturdayEvening)) - - assertTrue(services[2].isAllDayOpen(DayOfWeek.SUNDAY)) - assertEquals(LocalTime.of(20, 0), services[1].openUntil(openingTime)) - assertEquals(null, services[1].opensAt(openingTime)) - - val timeOpenAt8 = OpeningTime(LocalTime.of(8, 0), LocalTime.of(12, 0)) - val timeOpenAt14 = OpeningTime(LocalTime.of(14, 0), LocalTime.of(18, 0)) - val hoursOpen = OpeningHours( - mapOf( - DayOfWeek.MONDAY to listOf(timeOpenAt8, timeOpenAt14), - DayOfWeek.TUESDAY to listOf(timeOpenAt8, timeOpenAt14), - DayOfWeek.WEDNESDAY to listOf(timeOpenAt8, timeOpenAt14), - DayOfWeek.THURSDAY to listOf(timeOpenAt8, timeOpenAt14), - DayOfWeek.FRIDAY to listOf(timeOpenAt8, timeOpenAt14), - DayOfWeek.SATURDAY to listOf(timeOpenAt8), - ) - ) - - assertEquals(hoursOpen, services[0].openingHours) - - assertEquals(LOCATION_NAME, pharmacies[0].name) - assertEquals(TELEMATIK_ID, pharmacies[0].telematikId) - - val location = pharmacies[0].location - - assertEquals(Location(8.597412, 53.590027), location) - - val address = pharmacies[0].address - assertEquals(ADDRESS_LINE, address.lines[0]) - assertEquals(ADDRESS_CITY, address.city) - assertEquals(ADDRESS_POSTAL_CODE, address.postalCode) - } - - @Test - fun `extract pharmacy one - with three roleCodes`() { - val (pharmacies, _, _) = PharmacyMapper.extractLocalPharmacyServices(bundle) - val roleCodes = pharmacies[0].roleCode - assertTrue(roleCodes.size == 3) - } - - @Test - fun `compare locations`() { - assertEquals(Location(1.12345678, 1.12345678), Location(1.12345678, 1.12345678)) - assertEquals(Location(1.12345678, 1.12345678), Location(1.123456789, 1.123456789)) - assertEquals(Location(1.12345678, 1.12345678), Location(1.123457, 1.123457)) - assertEquals(Location(-1.12345678, 1.12345678), Location(-1.123457, 1.123457)) - - assertFalse(Location(1.12345678, 1.12345678) == Location(0.123457, 1.12345678)) - assertFalse(Location(1.12345678, 1.12345678) == Location(1.12345678, -1.12345678)) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModelTest.kt index 48466232..5d429b8d 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModelTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchViewModelTest.kt @@ -18,121 +18,170 @@ package de.gematik.ti.erp.app.pharmacy.ui -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import de.gematik.ti.erp.app.common.usecase.HintUseCase +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.fhir.model.PharmacyContacts +import de.gematik.ti.erp.app.pharmacy.ui.model.PharmacyScreenData +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyOverviewUseCase import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.listOfUIPrescriptions -import de.gematik.ti.erp.app.utils.testUIPrescription -import io.mockk.every +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk -import junit.framework.Assert.assertFalse -import junit.framework.Assert.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.test.assertEquals @ExperimentalCoroutinesApi class PharmacySearchViewModelTest { - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @get:Rule val coroutineRule = CoroutineTestRule() private lateinit var viewModel: PharmacySearchViewModel private lateinit var useCase: PharmacySearchUseCase - private lateinit var hintUseCase: HintUseCase + private lateinit var oftenUseCase: PharmacyOverviewUseCase + private lateinit var profileUseCase: ProfilesUseCase + + private val profile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = true, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage, + lastAuthenticated = null, + ssoTokenScope = null + ) + + // private val tasks = listOf("A", "B", "C") + private val prescriptions = listOf( + PharmacyUseCaseData.PrescriptionOrder( + taskId = "A", + accessCode = "1234", + title = "Test", + substitutionsAllowed = false + ), + PharmacyUseCaseData.PrescriptionOrder( + taskId = "B", + accessCode = "1234", + title = "Test", + substitutionsAllowed = false + ), + PharmacyUseCaseData.PrescriptionOrder( + taskId = "C", + accessCode = "1234", + title = "Test", + substitutionsAllowed = false + ) + ) + + private val pharmacy = PharmacyUseCaseData.Pharmacy( + name = "Test - Pharmacy", + address = null, + location = null, + distance = null, + contacts = PharmacyContacts(phone = "", mail = "", url = ""), + provides = listOf(), + openingHours = null, + telematikId = "", + ready = false + ) + + private val orderOption = PharmacyScreenData.OrderOption.ReserveInPharmacy + + private val contacts = PharmacyUseCaseData.ShippingContact( + name = "Beate Muster", + line1 = "Friedrichstraße 136", + line2 = "", + postalCodeAndCity = "10117 Berlin", + telephoneNumber = "", + mail = "", + deliveryInformation = "" + ) @Before fun setUp() { useCase = mockk() - hintUseCase = mockk() - viewModel = PharmacySearchViewModel(mockk(), useCase, hintUseCase, coroutineRule.testDispatchProvider) + profileUseCase = mockk() + oftenUseCase = mockk() + viewModel = PharmacySearchViewModel( + useCase = useCase, + pharmacyOverviewUseCase = oftenUseCase, + profilesUseCase = profileUseCase, + dispatchers = coroutineRule.dispatchers + ) + coEvery { profileUseCase.profiles } returns flowOf(listOf(profile)) + coEvery { useCase.prescriptionDetailsForOrdering("") } returns flowOf( + PharmacyUseCaseData.OrderState( + prescriptions = prescriptions, + contact = contacts + ) + ) + coEvery { oftenUseCase.isPharmacyInFavorites(any()) } returns false + runBlocking { viewModel.onSelectPharmacy(pharmacy = pharmacy) } + viewModel.onSelectOrderOption(orderOption) } @Test - fun `tests fetching of orders from db - list is not empty`() = - coroutineRule.testDispatcher.runBlockingTest { - every { useCase.prescriptionDetailsForOrdering(any()) } answers { - flowOf( - listOfUIPrescriptions() - ) - } - viewModel.fetchSelectedOrders(listOf("")).collect { - assertTrue(it.isNotEmpty()) - } - } + fun `order screen state - default`() = runTest { + val state = viewModel.orderScreenState().first() - @Test - fun `tests fetching of orders from db - element is not selected`() = - coroutineRule.testDispatcher.runBlockingTest { - val uiPrescriptionOrder = testUIPrescription() - every { useCase.prescriptionDetailsForOrdering(any()) } answers { - flowOf( - listOf( - uiPrescriptionOrder - ) - ) - } - viewModel.fetchSelectedOrders(listOf("")).collect { - assertFalse(it.first().selected) - } - } + assertEquals(profile, state.activeProfile) + assertEquals(contacts, state.contact) + assertEquals(pharmacy, state.selectedPharmacy) + assertEquals(orderOption, state.orderOption) + assertEquals(prescriptions.map { Pair(it, true) }, state.prescriptions) + } @Test - fun `tests fetching of orders from db - element is selected`() = - coroutineRule.testDispatcher.runBlockingTest { - val uiPrescriptionOrder = testUIPrescription() - uiPrescriptionOrder.selected = false - viewModel.toggleOrder(uiPrescriptionOrder) - every { useCase.prescriptionDetailsForOrdering(any()) } answers { - flowOf( - listOf( - uiPrescriptionOrder - ) - ) - } - viewModel.fetchSelectedOrders(listOf("")).collect { - assertTrue(it.first().selected) - } - } + fun `order screen state - select prescriptions`() = runTest { + viewModel.onSelectOrder(prescriptions[0]) + viewModel.onSelectOrder(prescriptions[1]) + viewModel.onSelectOrder(prescriptions[2]) - @Test - fun `tests toggling order - adds order to list of orders`() { - val uiPrescriptionOrder = testUIPrescription() - uiPrescriptionOrder.selected = false - val result = viewModel.toggleOrder(uiPrescriptionOrder) - assertTrue(result) - } + viewModel.onDeselectOrder(prescriptions[0]) - @Test - fun `tests toggling order - removes order from list of orders`() { - val uiPrescriptionOrder = testUIPrescription() - uiPrescriptionOrder.selected = true - val result = viewModel.toggleOrder(uiPrescriptionOrder) - assertFalse(result) - } + val state = viewModel.orderScreenState().first() - @Test - fun `tests fabState enabled - should be false`() { - val uiPrescriptionOrder = testUIPrescription() - viewModel.toggleOrder(uiPrescriptionOrder) - val result = viewModel.uiState.fabState - assertFalse(result) + assertEquals(profile, state.activeProfile) + assertEquals(contacts, state.contact) + assertEquals(pharmacy, state.selectedPharmacy) + assertEquals(orderOption, state.orderOption) + assertEquals( + listOf(Pair(prescriptions[0], false), Pair(prescriptions[1], true), Pair(prescriptions[2], true)), + state.prescriptions + ) } @Test - fun `tests fabState enabled - should be true`() { - val uiPrescriptionOrder = testUIPrescription() - uiPrescriptionOrder.selected = false - viewModel.toggleOrder(uiPrescriptionOrder) - val result = viewModel.uiState.fabState - assertTrue(result) + fun `order screen state - set contacts`() = runTest { + coEvery { useCase.saveShippingContact(any()) } answers {} + coEvery { useCase.prescriptionDetailsForOrdering("") } returns flowOf( + PharmacyUseCaseData.OrderState( + prescriptions = prescriptions, + contact = contacts + ) + ) + + viewModel.onSaveContact(contacts) + + coroutineRule.testDispatcher.scheduler.runCurrent() + coVerify(exactly = 1) { useCase.saveShippingContact(contacts) } + + val state = viewModel.orderScreenState().first() + + assertEquals(profile, state.activeProfile) + assertEquals(contacts, state.contact) + assertEquals(pharmacy, state.selectedPharmacy) + assertEquals(orderOption, state.orderOption) + assertEquals(prescriptions.map { Pair(it, true) }, state.prescriptions) } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacySearchUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacySearchUseCaseTest.kt deleted file mode 100644 index 1e19d508..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/pharmacy/usecase/model/PharmacySearchUseCaseTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.pharmacy.usecase.model - -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase -import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository -import de.gematik.ti.erp.app.prescription.repository.RemoteRedeemOption -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.testTasks -import de.gematik.ti.erp.app.utils.testUIPrescription -import io.mockk.coEvery -import io.mockk.mockk -import junit.framework.Assert.assertTrue -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class PharmacySearchUseCaseTest { - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private lateinit var useCase: PharmacySearchUseCase - private lateinit var repository: PrescriptionRepository - private lateinit var moshi: Moshi - private lateinit var profilesUseCase: ProfilesUseCase - - @Before - fun setUp() { - repository = - PrescriptionRepository(coroutineRule.testDispatchProvider, mockk(), mockk(), mockk()) - moshi = Moshi.Builder().build() - profilesUseCase = mockk() - useCase = PharmacySearchUseCase( - mockk(), - repository, - mockk(relaxed = true), - mockk(), - moshi, - coroutineRule.testDispatchProvider, - profilesUseCase - ) - coEvery { - repository.redeemPrescription( - any(), - any() - ) - } answers { Result.Success("".toResponseBody()) } - coEvery { repository.loadTasksForTaskId(any()) } answers { flow { testTasks() } } - coEvery { profilesUseCase.activeProfileName() } answers { flowOf("tester") } - } - - @Test - fun `tests redeemPrescription`() = - coroutineRule.testDispatcher.runBlockingTest { - val redeemOption = RemoteRedeemOption.Local - val uiPrescriptionOrder = testUIPrescription() - val telematicsId = "foo" - val result = useCase.redeemPrescription(redeemOption, uiPrescriptionOrder, telematicsId) - assertTrue(result is Result.Success) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/MapperTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/MapperTest.kt index bca98ef4..77885455 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/MapperTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/prescription/MapperTest.kt @@ -16,338 +16,166 @@ * */ -package de.gematik.ti.erp.app.prescription - -import ca.uhn.fhir.context.FhirContext -import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.prescription.repository.FhirCoverage -import de.gematik.ti.erp.app.prescription.repository.FhirMedication -import de.gematik.ti.erp.app.prescription.repository.FhirMedicationRequest -import de.gematik.ti.erp.app.prescription.repository.FhirOrganization -import de.gematik.ti.erp.app.prescription.repository.FhirPatient -import de.gematik.ti.erp.app.prescription.repository.FhirPractitioner -import de.gematik.ti.erp.app.prescription.repository.Mapper -import de.gematik.ti.erp.app.prescription.repository.NormSize -import de.gematik.ti.erp.app.prescription.repository.accessCode -import de.gematik.ti.erp.app.prescription.repository.extractKBVBundle -import de.gematik.ti.erp.app.prescription.repository.extractKBVBundleReference -import de.gematik.ti.erp.app.prescription.repository.extractResource -import de.gematik.ti.erp.app.prescription.repository.extractResourceForReference -import de.gematik.ti.erp.app.prescription.repository.extractResources -import de.gematik.ti.erp.app.prescription.repository.findReferences -import de.gematik.ti.erp.app.prescription.repository.mapToUi -import de.gematik.ti.erp.app.prescription.repository.prescriptionId -import de.gematik.ti.erp.app.utils.emptyTestBundle -import de.gematik.ti.erp.app.utils.taskWithDirectAssignmentWithoutKBVBundle -import de.gematik.ti.erp.app.utils.testBundle -import de.gematik.ti.erp.app.utils.testCommunicationBundle -import de.gematik.ti.erp.app.utils.testMedicationDispenseBundle -import de.gematik.ti.erp.app.utils.testSingleKBVBundle -import java.time.LocalDate -import java.time.OffsetDateTime -import java.time.ZoneId -import java.time.temporal.ChronoUnit -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Communication -import org.hl7.fhir.r4.model.Composition -import org.hl7.fhir.r4.model.MedicationDispense -import org.hl7.fhir.r4.model.MedicationRequest -import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.Task -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Before -import org.junit.Test - -const val KBVBUNDLE_REFERENCE = "a02c3b44-0500-0000-0002-000000000000" -const val ACCESS_CODE = "68db761b666f7e75a32090fd4d109e2766e02693741278ab6dc2df90f1cbb3af" -const val PRESCRIPTION_ID = "160.000.088.357.676.93" -const val PRACTITIONER_ID = "Practitioner/20597e0e-cb2a-45b3-95f0-dc3dbdb617c3" -const val TASK_ID = "160.000.088.357.676.93" -const val KBV_REFERENCE = "#5c310594-1dd2-11b2-803b-63bf44e44fb8" -const val PATIENT_ID = "Patient/9774f67f-a238-4daf-b4e6-679deeef3811" -const val PATIENT_IDENTIFIER = "X110498793" -const val PATIENT_ADDRESS_LINE = "Siegburger Str. 155, 51105, Köln" -const val PATIENT_BIRTH_DATE = "1964-04-04" -const val PRESCRIPTION_FLOW_TYPE_CODE = 160 -const val PATIENT_NAME = "Prof. Dr. Karl-Friederich Graf Freiherr von Schaumberg" -const val PATIENT_INSURANCE_NAME = "AOK Rheinland/Hamburg" -const val PATIENT_TITLE = "" -const val PRACTITIONER_NAME = "Prof. Dr. Hannelore Popówitsch" -const val PRACTITIONER_QUALIFICATION = "Innere und Allgemeinmedizin (Hausarzt)" -const val PRACTITIONER_LANR = "445588777" -const val MEDICATION_ID = "Medication/5fe6e06c-8725-46d5-aecd-e65e041ca3de" - -const val ORGANIZATION_NAME = "Universitätsklinik Campus Süd" -const val ORGANIZATION_PHONE = "06841/7654321" -val ORGANIZATION_MAIL = "unikliniksued@test.de" -const val ORGANIZATION_ADDRESS = "Kirrberger Str. 100" -const val ORGANIZATION_BSNR = "998877665" - -const val AUTHORED_ON = "2021-11-30" - -const val EXPIRES_ON = "2022-03-02" -const val ACCEPT_UNTIL = "2021-12-28" -const val LAST_MODIFIED = "2021-11-30T14:17:39.222+00:00" - -const val MEDICATION_TEXT = "Olanzapin Heumann 20mg" -const val MEDICATION_TYPE = R.string.kbv_code_dosage_form_smt -val MEDICATION_SIZE = NormSize("N3", R.string.kbv_norm_size_n3) // "N3 - Normgröße 3" -const val MEDICATION_PZN = "08850519" - -const val COVERAGE_NAME = "AOK Nordost - Die Gesundheitskasse" -const val COVERAGE_STATUS = R.string.kbv_member_status_1 // "Mitglied" - -val ACCIDENT_DATE = null -val ACCIDENT_LOCATION = null -const val EMERGENCY_FEE = false -const val SUBSTITUTION_ALLOWED = false - -const val MED_DISPENSE_ID = "160.000.000.012.852.10" -const val MED_DISPENSE_PATIENT_ID = "X110497056" -const val MED_DISPENSE_UNIQUE_ID = "06313728" -const val MED_DISPENSE_WAS_SUBSTITUTED = false -const val MED_DISPENSE_DOSAGE_INSTRUCTION = "1-0-1-0" -const val MED_DISPENSE_PERFORMER = "3-SMC-B-Testkarte-883110000129068" -const val MED_DISPENSE_WHEN_HANDED_OVER = "2021-06-29T07:29:19Z" - -class MapperTest { - - lateinit var mapper: Mapper - lateinit var bundle: Bundle - lateinit var singleKBVBundle: Bundle - lateinit var medicationDispenseBundle: MedicationDispense - - @Before - fun setup() { - mapper = Mapper(FhirContext.forR4().newJsonParser()) - bundle = testBundle() - singleKBVBundle = testSingleKBVBundle() - medicationDispenseBundle = testMedicationDispenseBundle() - } - - @Test - fun `parse bundle for tasks and assure tasks are not null`() { - val task = bundle.extractResources() - assertNotNull(task) - } - - @Test - fun `parse bundle for task with direct assignment and assure tasks are not null`() { - bundle = taskWithDirectAssignmentWithoutKBVBundle() - val task = bundle.extractResources() - assertNotNull(task) - } - - @Test - fun `parse bundle for communications and assure communications are not null`() { - val commBundle = testCommunicationBundle() - val communication = commBundle.extractResources() - assertNotNull(communication) - } - - @Test - fun `parse bundle for communications - no given communications`() { - bundle = emptyTestBundle() - val communications = bundle.extractResources() - assertNotNull(communications) - assert(communications!!.isEmpty()) - } - - @Test - fun `map bundle to communication`() { - val commBundle = testCommunicationBundle() - val communication = mapper.mapFhirBundleToCommunications(commBundle, "") - communication[0].let { - assertEquals("16d2cfc8-2023-11b2-81e1-783a425d8e87", it.communicationId) - assertEquals("39c67d5b-1df3-11b2-80b4-783a425d8e87", it.taskId) - assertEquals("3-09.2.S.10.743", it.telematicsId) - assertEquals("{do something}", it.payload) - } - communication[1].let { - assertEquals("e277e66f-2345-11b2-86e3-783a425d8e87", it.communicationId) - assertEquals("39c67d5b-1df3-11b2-80b4-783a425d8e87", it.taskId) - assertEquals("3-17.2.1234560000.10.372", it.telematicsId) - assertEquals("{do something}", it.payload) - } - } - - @Test - fun `parse bundle for tasks - no given tasks`() { - bundle = emptyTestBundle() - val tasks = bundle.extractResources() - assertNotNull(tasks) - assert(tasks!!.isEmpty()) - } - - @Test - fun `search for resourceType that is not there in bundle - should return empty list`() { - val tasks = bundle.extractResources() - assertNotNull(tasks) - assert(tasks!!.isEmpty()) - } - - @Test - fun `read bundle and extract KBVBundle reference`() { - val tasks = bundle.extractResources() - val kbvBundleReference = tasks!![0].extractKBVBundleReference() - assertNotNull(kbvBundleReference) - assertEquals(KBVBUNDLE_REFERENCE, kbvBundleReference) - } - - @Test - fun `extract KBVBundle with given reference`() { - val tasks = bundle.extractResources() - val kbvBundleReference = tasks!![0].extractKBVBundleReference() - assertNotNull(kbvBundleReference) - val kbvBundle = bundle.extractKBVBundle(kbvBundleReference!!) - assertNotNull(kbvBundle) - } - - @Test - fun `extract accessCode from Task - should not be null`() { - val tasks = bundle.extractResources() - val accessCode = tasks!![0].accessCode() - assertNotNull(accessCode) - assertEquals(ACCESS_CODE, accessCode) - } - - @Test - fun `extract prescriptionId from Task - should not be null`() { - val tasks = bundle.extractResources() - val prescriptionID = tasks!![0].prescriptionId() - assertNotNull(prescriptionID) - assertEquals(PRESCRIPTION_ID, prescriptionID) - } - - @Test - fun `extract resource for reference`() { - val tasks = bundle.extractResources() - val kbvReference = tasks!![0].extractKBVBundleReference() - assertNotNull(kbvReference) - val kbvBundle = bundle.extractKBVBundle(kbvReference!!) - - val medicationRequest = - kbvBundle?.extractResource() - val references = medicationRequest?.findReferences() - references?.let { - val patient = - kbvBundle.extractResourceForReference(reference = it["patient"] ?: "foo") - assertNotNull(patient) - } - } - - @Test - fun `map bundle to Task`() { - val task = mapper.mapFhirBundleToTaskWithKBVBundle(bundle, "") - - assertEquals(TASK_ID, task.taskId) - assertEquals(ACCESS_CODE, task.accessCode) - assertEquals( - OffsetDateTime.parse(LAST_MODIFIED).truncatedTo(ChronoUnit.SECONDS), - task.lastModified - ) - assertEquals(ORGANIZATION_NAME, task.organization) - assertEquals(MEDICATION_TEXT, task.medicationText) - - assertEquals( - LocalDate.parse(EXPIRES_ON), - task.expiresOn - ) - assertEquals( - LocalDate.parse(ACCEPT_UNTIL), - task.acceptUntil - ) - assertEquals( - LocalDate.parse(AUTHORED_ON), - task.authoredOn?.atZoneSameInstant(ZoneId.systemDefault())?.toLocalDate() - ) - } - - @Test - fun `parse kbv bundle`() { - val task = mapper.mapFhirBundleToTaskWithKBVBundle(bundle, "") - - mapper.parseKBVBundle(task.rawKBVBundle!!) - } - - @Test - fun `map kbv bundle to PatientDetail`() { - val patientDetail = - testSingleKBVBundle().extractResources()!!.first().mapToUi() - - assertEquals(PATIENT_NAME, patientDetail.name) - assertEquals(PATIENT_ADDRESS_LINE, patientDetail.address) - assertEquals(LocalDate.parse(PATIENT_BIRTH_DATE), patientDetail.birthdate) - assertEquals(PATIENT_IDENTIFIER, patientDetail.insuranceIdentifier) - } - - @Test - fun `map kbv bundle to PractitionerDetail`() { - val practitionerDetail = - testSingleKBVBundle().extractResources()!!.first().mapToUi() - - assertEquals(PRACTITIONER_NAME, practitionerDetail.name) - assertEquals(PRACTITIONER_QUALIFICATION, practitionerDetail.qualification) - assertEquals(PRACTITIONER_LANR, practitionerDetail.practitionerIdentifier) - } - - @Test - fun `map kbv bundle to MedicationDetail`() { - val medicationDetail = - testSingleKBVBundle().extractResources()!!.first().mapToUi() - - assertEquals(MEDICATION_TEXT, medicationDetail.text) - assertEquals(MEDICATION_SIZE, medicationDetail.normSize) - assertEquals(MEDICATION_TYPE, medicationDetail.type) - assertEquals(MEDICATION_PZN, medicationDetail.uniqueIdentifier) - } - - @Test - fun `map kbv bundle to InsuranceCompany`() { - val coverageDetail = - testSingleKBVBundle().extractResources()!!.first().mapToUi() - - assertEquals(COVERAGE_NAME, coverageDetail.name) - assertEquals(COVERAGE_STATUS, coverageDetail.status) - } - - @Test - fun `map kbv bundle to OrganizationDetail`() { - val organizationDetail = - testSingleKBVBundle().extractResources()!!.first().mapToUi() - - assertEquals(ORGANIZATION_NAME, organizationDetail.name) - assertEquals(ORGANIZATION_ADDRESS, organizationDetail.address) - assertEquals(ORGANIZATION_MAIL, organizationDetail.mail) - assertEquals(ORGANIZATION_PHONE, organizationDetail.phone) - assertEquals(ORGANIZATION_BSNR, organizationDetail.uniqueIdentifier) - } - - @Test - fun `map kbv bundle to MedicationRequestDetail`() { - val accidentDetail = - testSingleKBVBundle().extractResources()!!.first().mapToUi() - - assertEquals(ACCIDENT_DATE, accidentDetail.dateOfAccident) - assertEquals(ACCIDENT_LOCATION, accidentDetail.location) - assertEquals(EMERGENCY_FEE, accidentDetail.emergencyFee) - assertEquals(SUBSTITUTION_ALLOWED, accidentDetail.substitutionAllowed) - } - - @Test - fun `map medication dispense to MedicationDispenseSimple`() { - val medicationDispenseSimple = - mapper.mapMedicationDispenseToMedicationDispenseSimple(medicationDispenseBundle) - assertEquals(MED_DISPENSE_ID, medicationDispenseSimple.taskId) - assertEquals(MED_DISPENSE_PATIENT_ID, medicationDispenseSimple.patientIdentifier) - assertEquals(MED_DISPENSE_UNIQUE_ID, medicationDispenseSimple.uniqueIdentifier) - assertEquals(MED_DISPENSE_WAS_SUBSTITUTED, medicationDispenseSimple.wasSubstituted) - assertEquals(MED_DISPENSE_DOSAGE_INSTRUCTION, medicationDispenseSimple.dosageInstruction) - assertEquals(MED_DISPENSE_PERFORMER, medicationDispenseSimple.performer) - assertEquals( - OffsetDateTime.parse(MED_DISPENSE_WHEN_HANDED_OVER).truncatedTo(ChronoUnit.SECONDS), - medicationDispenseSimple.whenHandedOver - ) - } -} +// TODO: work in progress - replace fhir parser + +// +// package de.gematik.ti.erp.app.prescription +// +// import ca.uhn.fhir.context.FhirContext +// import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +// import de.gematik.ti.erp.app.prescription.repository.FhirOrganization +// import de.gematik.ti.erp.app.prescription.repository.extractResources +// import de.gematik.ti.erp.app.prescription.repository.toOrganizationEntityV1 +// import org.hl7.fhir.r4.model.Bundle +// import org.hl7.fhir.r4.model.Organization +// import org.hl7.fhir.r4.model.Property +// import java.util.zip.ZipEntry +// import java.util.zip.ZipFile +// import java.util.zip.ZipInputStream +// import kotlin.test.BeforeTest +// import kotlin.test.Test +// import kotlin.test.assertEquals +// +// private typealias FlatBundle = Map +// +// class MapperTest { +// +// @BeforeTest +// fun setUp() { +// } +// +// @Test +// fun `medication request`() { +// val parser = FhirContext.forR4().newXmlParser() +// +// ZipFile("src/test/res/KBV_1.0.2_1000_Auswahl.zip").use { zip -> +// zip.entries().asSequence().forEach { entry -> +// println(entry.name) +// zip.getInputStream(entry).use { input -> +// val bundle = try { +// parser.parseResource(input) as Bundle +// } catch (e : Exception) { +// println(e) +// null +// } +// +// if (bundle != null) { +// val flatBundle = walkBundle(bundle) +// +// flatBundle.print() +// +// testOrganizationEntityV1( +// flatBundle, +// bundle.extractResources().firstOrNull()!!.toOrganizationEntityV1() +// ) +// +// flatBundle.print() +// } +// } +// return@use +// } +// } +// } +// +// private fun testOrganizationEntityV1(flatBundle: FlatBundle, organization: OrganizationEntityV1) { +// val (organizationIndex) = flatBundle.indicesFor("resource","Organization") +// +// assertEquals(flatBundle["entry{$organizationIndex}.resource{0}.name"], organization.name) +// +// val (phoneIndex) = flatBundle.indicesFor("system", "phone", "entry{$organizationIndex}.resource{0}.telecom") +// +// val telecomPrefix = "entry{$organizationIndex}.resource{0}" +// assertEquals(flatBundle["$telecomPrefix.telecom{$phoneIndex}.system"], "phone") +// assertEquals(flatBundle["$telecomPrefix.telecom{$phoneIndex}.value"], organization.phone) +// // assertEquals(flatBundle["$telecomPrefix.telecom{$phoneIndex}.system"], "fax") +// // assertEquals(flatBundle["$telecomPrefix.telecom{$phoneIndex}.value"], organization.fax) +// assertEquals(flatBundle["$telecomPrefix.telecom{$phoneIndex}.system"], "email") +// assertEquals(flatBundle["$telecomPrefix.telecom{$phoneIndex}.value"], organization.mail) +// // entry{5}.resource{0}.telecom{0}.value: 0301234567 +// // entry{5}.resource{0}.telecom{1}.system: fax +// // entry{5}.resource{0}.telecom{1}.value: 030123456789 +// // entry{5}.resource{0}.telecom{2}.system: email +// // entry{5}.resource{0}.telecom{2}.value: mvz@e-mail.de +// // entry{5}.resource{0}.address{0}.type: both +// // entry{5}.resource{0}.address{0}.line: Herbert-Lewin-Platz 2 +// +// } +// +// +// +// // fun List>.valueOf(path: String) = find { (p, _) -> p == path }?.second +// // +// // fun List>.pathPrefixForResource(type: String, prefix: String = ""): String? = +// // this.find { (name, value) -> +// // name.startsWith(prefix) && name.endsWith("resource") && value == type +// // }?.let { (name) -> +// // name +// // } +// +// +// // +// // fun List>.pathPrefixFor(path: String, value: String, prefix: String = ""): String? { +// // val pathRegex = path.replace("{?}", "\\{\\d}").toRegex() +// // +// // return find { (name, v) -> +// // if (name.startsWith(prefix)) { +// // name.removePrefix(prefix).matches(pathRegex) && v == value +// // } else { +// // false +// // } +// // }?.let { (name) -> +// // name +// // } +// // } +// // +// // fun String.popPath() = +// // this@popPath.substringBeforeLast('.') +// +// +// } +// +// private fun FlatBundle.print() { +// forEach { (k, v) -> +// println("$k: $v") +// } +// } +// +// private val rg = """\{(\d)}""".toRegex() +// +// private fun FlatBundle.indicesFor(type: String, value: String, prefix: String = ""): List { +// // find matching entry +// val entry = entries.find { (name, v) -> +// name.startsWith(prefix) && name.endsWith(type) && v == value +// } +// +// // return all indices contained in `{?}` +// return entry?.let { (name) -> +// rg.findAll(name.removePrefix(prefix)).map { match -> +// match.groupValues[1].toInt() +// }.toList() +// } ?: emptyList() +// } +// +// private fun walkBundle(bundle: Bundle): FlatBundle { +// val out = mutableMapOf() +// bundle.children().forEach { +// walk(property = it, name = null, out = out) +// } +// return out +// } +// +// private fun walk(property: Property, name: String?, out: MutableMap) { +// val prefix = name?.let { "$name.${property.name}" } ?: property.name +// +// property.values.forEachIndexed { index, base -> +// if (base.isPrimitive) { +// out[prefix] = base.primitiveValue() +// } +// if (base.isResource) { +// out[prefix] = base.fhirType() +// } +// base.children().forEach { +// walk(it, "$prefix{$index}", out) +// } +// } +// } diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModelTest.kt deleted file mode 100644 index e0426e8a..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/PrescriptionDetailsViewModelTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.detail.ui - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.detailPrescriptionScanned -import io.mockk.coEvery -import io.mockk.mockk -import junit.framework.Assert.assertEquals -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class PrescriptionDetailsViewModelTest { - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private lateinit var viewModel: PrescriptionDetailsViewModel - private lateinit var useCase: PrescriptionUseCase - - @Before - fun setup() { - useCase = mockk() - viewModel = - PrescriptionDetailsViewModel(useCase, coroutineRule.testDispatchProvider) - } - - @Test - fun `test loading task`() = coroutineRule.testDispatcher.runBlockingTest { - val expected = detailPrescriptionScanned() - coEvery { useCase.generatePrescriptionDetails(any()) } returns expected - coEvery { useCase.unRedeemMorePossible(any(), any()) } returns true - - assertEquals(expected, viewModel.detailedPrescription(expected.taskId)) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/MapperTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/MapperTest.kt deleted file mode 100644 index 6578ecc4..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/MapperTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.detail.ui.model - -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.prescription.repository.extractMedication -import de.gematik.ti.erp.app.prescription.repository.extractMedicationRequest -import de.gematik.ti.erp.app.prescription.repository.extractPatient -import de.gematik.ti.erp.app.prescription.usecase.createMatrixCode -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import de.gematik.ti.erp.app.utils.testScannedTasks -import de.gematik.ti.erp.app.utils.testSingleKBVBundle -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test - -class MapperTest { - - private lateinit var task: Task - private lateinit var matrix: BitMatrixCode - - @Before - fun setup() { - task = testScannedTasks[0] - matrix = BitMatrixCode(createMatrixCode("somePayload")) - } - - @Test - fun `test mapToUIPrescriptionDetail`() { - val uiDetail = mapToUIPrescriptionDetailScanned(task, matrix, true) - assertEquals(uiDetail.taskId, task.taskId) - assertEquals(uiDetail.accessCode, task.accessCode) - assertEquals(uiDetail.number, task.nrInScanSession) - assertEquals(uiDetail.scannedOn, task.scannedOn) - } - - @Test - fun `test mapToUIPrescriptionOrder`() { - val bundle = testSingleKBVBundle() - val uiPrescriptionOrder = mapToUIPrescriptionOrder( - task, - requireNotNull(bundle.extractMedication()), - requireNotNull(bundle.extractMedicationRequest()), - requireNotNull(bundle.extractPatient()), - ) - assertEquals(uiPrescriptionOrder.taskId, task.taskId) - assertEquals(uiPrescriptionOrder.accessCode, task.accessCode) - assertEquals(uiPrescriptionOrder.selected, true) - assertEquals("Siegburger Str. 155, 51105, Köln", uiPrescriptionOrder.address) - assertEquals("Prof. Dr. Karl-Friederich Graf Freiherr von Schaumberg", uiPrescriptionOrder.patientName) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepositoryTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepositoryTest.kt deleted file mode 100644 index 11d7b139..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepositoryTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.repository - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import ca.uhn.fhir.context.FhirContext -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.allAuditEvents -import de.gematik.ti.erp.app.utils.emptyAuditEvents -import de.gematik.ti.erp.app.utils.taskWithBundle -import de.gematik.ti.erp.app.utils.taskWithoutKBVBundle -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.MockK -import java.io.IOException -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class PrescriptionRepositoryTest { - - private lateinit var prescriptionRepository: PrescriptionRepository - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - @get:Rule - val coroutineRule = CoroutineTestRule() - - @MockK - lateinit var localDataSource: LocalDataSource - - @MockK - lateinit var remoteDataSource: RemoteDataSource - - var lastModifiedTask = Instant.now() - var lastModifiedAudit = OffsetDateTime.now() - - var mapper: Mapper = Mapper(FhirContext.forR4().newJsonParser()) - - private val taskWithoutKBVBundle = taskWithoutKBVBundle() - private val allAuditEvents = allAuditEvents() - - @Before - fun setup() { - MockKAnnotations.init(this) - prescriptionRepository = PrescriptionRepository( - coroutineRule.testDispatchProvider, - localDataSource, - remoteDataSource, - mapper - ) - coEvery { remoteDataSource.fetchTasks(lastModifiedTask, any()) } answers { - Result.Success( - taskWithoutKBVBundle - ) - } - coEvery { remoteDataSource.allAuditEvents(any(), lastModifiedAudit, null, null) } answers { - Result.Success( - allAuditEvents - ) - } - coEvery { remoteDataSource.taskWithKBVBundle(any(), any()) } answers { - Result.Success( - taskWithBundle() - ) - } - coEvery { remoteDataSource.fetchCommunications(any()) } coAnswers { Result.Error(IOException()) } - - coEvery { localDataSource.saveAuditEvents(any()) } answers { nothing } - coEvery { localDataSource.saveTask(any()) } answers { nothing } - coEvery { localDataSource.taskSyncedUpTo(any()) } answers { lastModifiedTask } - coEvery { localDataSource.updateTaskSyncedUpTo(any(), any()) } answers { } - coEvery { localDataSource.deleteLowDetailEvents(any()) } answers { nothing } - coEvery { localDataSource.auditEventsSyncedUpTo(any()) } answers { lastModifiedAudit } - } - - @Test - fun `if download tasks gets called - ensure that complete tasks are saved`() = - coroutineRule.testDispatcher.runBlockingTest { - val emptyAuditEvents = emptyAuditEvents() - - coEvery { localDataSource.auditEventsSyncedUpTo(any()) } returns Instant.ofEpochSecond(0) - .atOffset(ZoneOffset.UTC) - coEvery { remoteDataSource.allAuditEvents(any(), any()) } answers { - Result.Success( - emptyAuditEvents - ) - } - prescriptionRepository.downloadTasks("") - coVerify(exactly = taskWithoutKBVBundle.entry.size) { localDataSource.saveTask(any()) } - coVerify { localDataSource.updateTaskSyncedUpTo(any(), any()) } - } - - @Test - fun `download auditEvents - stores synced up time each page`() { - val timestamp = OffsetDateTime.parse("2022-01-03T09:11:30+02:00") - val profileName = "Test" - coEvery { localDataSource.auditEventsSyncedUpTo(profileName) } returns timestamp - coEvery { localDataSource.setAllAuditEventsSyncedUpTo(profileName) } answers { } - - coEvery { - remoteDataSource.allAuditEvents( - profileName = profileName, - lastKnownUpdate = timestamp, - count = 50, - offset = null - ) - } answers { - Result.Success(allAuditEvents()) - } andThenAnswer { - Result.Success(emptyAuditEvents()) - } - coEvery { localDataSource.saveAuditEvents(any()) } answers { } - - coroutineRule.testDispatcher.runBlockingTest { - prescriptionRepository.downloadAllAuditEvents(profileName) - } - - coVerify(exactly = 2) { localDataSource.setAllAuditEventsSyncedUpTo(profileName) } - } - - @Test - fun `failed to download auditEvents - doesn't save any audits`() { - val timestamp = OffsetDateTime.parse("2022-01-03T09:11:30+02:00") - val profileName = "Test" - coEvery { localDataSource.auditEventsSyncedUpTo(profileName) } returns timestamp - coEvery { localDataSource.setAllAuditEventsSyncedUpTo(profileName) } answers { } - - coEvery { - remoteDataSource.allAuditEvents( - profileName = profileName, - lastKnownUpdate = timestamp, - count = 50, - offset = null - ) - } answers { - Result.Error(IllegalArgumentException("")) - } - - coroutineRule.testDispatcher.runBlockingTest { - prescriptionRepository.downloadAllAuditEvents(profileName) - } - - coVerify(exactly = 0) { localDataSource.setAllAuditEventsSyncedUpTo(profileName) } - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSourceTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSourceTest.kt deleted file mode 100644 index 56af4536..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/RemoteDataSourceTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.repository - -import de.gematik.ti.erp.app.api.ErpService -import de.gematik.ti.erp.app.api.FhirConverterFactory -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.di.LazyFhirParser -import de.gematik.ti.erp.app.utils.enqueueResponse -import kotlinx.coroutines.runBlocking -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockWebServer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import retrofit2.Retrofit -import java.security.SecureRandom -import java.security.cert.CertificateException -import java.security.cert.X509Certificate -import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager - -class RemoteDataSourceTest { - - private lateinit var remoteDataSource: RemoteDataSource - - private val mockWebServer = MockWebServer() - - private val client = getUnsafeOkHttpClient() - - private val api = Retrofit.Builder() - .baseUrl(mockWebServer.url("/")) - .client(client) - .addConverterFactory(FhirConverterFactory.create(LazyFhirParser())) - .build() - .create(ErpService::class.java) - - @Before - fun setUp() { - remoteDataSource = RemoteDataSource(api) - } - - @After - fun tearDown() { - mockWebServer.shutdown() - } - - @Test - fun fetchTasks() = runBlocking { - mockWebServer.enqueueResponse("taskResponse.txt", 200) - val actual = remoteDataSource.fetchTasks(null, "") - val expected = "Task" - assertTrue(actual is Result.Success) - assertEquals(expected, (actual as Result.Success).data.entry[0].resource.resourceType.name) - } - - private fun getUnsafeOkHttpClient(): OkHttpClient? { - return try { - // Create a trust manager that does not validate certificate chains - val trustAllCerts: Array = arrayOf( - object : X509TrustManager { - @Throws(CertificateException::class) - override fun checkClientTrusted( - chain: Array?, - authType: String? - ) { - } - - @Throws(CertificateException::class) - override fun checkServerTrusted( - chain: Array?, - authType: String? - ) { - } - - override fun getAcceptedIssuers(): Array? { - return arrayOfNulls(0) - } - } - ) - - // Install the all-trusting trust manager - val sslContext: SSLContext = SSLContext.getInstance("SSL") - sslContext.init(null, trustAllCerts, SecureRandom()) - // Create an ssl socket factory with our all-trusting manager - val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory - OkHttpClient.Builder() - .connectTimeout(1, TimeUnit.SECONDS) - .readTimeout(1, TimeUnit.SECONDS) - .writeTimeout(1, TimeUnit.SECONDS) - .sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager) - .hostnameVerifier { _, _ -> true }.build() - } catch (e: Exception) { - throw RuntimeException(e) - } - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModelTest.kt deleted file mode 100644 index 6b5c12b4..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModelTest.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.ui - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import de.gematik.ti.erp.app.cardwall.usecase.AuthenticationUseCase -import de.gematik.ti.erp.app.common.usecase.HintUseCase -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintDefineSecurity -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintDemoModeActivated -import de.gematik.ti.erp.app.common.usecase.model.PrescriptionScreenHintTryDemoMode -import de.gematik.ti.erp.app.db.entities.Settings -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.settings.usecase.SettingsUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.impl.annotations.RelaxedMockK -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class PrescriptionViewModelTest { - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - @get:Rule - val coroutineRule = CoroutineTestRule() - - @MockK - private lateinit var viewModel: PrescriptionViewModel - - @RelaxedMockK - private lateinit var prescriptionUseCase: PrescriptionUseCase - - @RelaxedMockK - private lateinit var profilesUseCase: ProfilesUseCase - - @MockK - private lateinit var demoUseCase: DemoUseCase - - @MockK - private lateinit var hintUseCase: HintUseCase - - @MockK - private lateinit var settingsUseCase: SettingsUseCase - - @MockK - private lateinit var authenticationUseCase: AuthenticationUseCase - - @Before - fun setup() { - MockKAnnotations.init(this) - - every { prescriptionUseCase.syncedRecipes() } answers { flowOf(listOf()) } - every { prescriptionUseCase.scannedRecipes() } answers { flowOf(listOf()) } - every { prescriptionUseCase.redeemedPrescriptions() } answers { flowOf(listOf()) } - - every { demoUseCase.demoModeActive } answers { MutableStateFlow(false) } - every { demoUseCase.isDemoModeActive } answers { false } - every { demoUseCase.demoModeHasBeenSeen } answers { false } - every { hintUseCase.cancelledHints } returns flowOf(setOf()) - every { hintUseCase.isHintCanceled(PrescriptionScreenHintTryDemoMode) } answers { false } - every { hintUseCase.isHintCanceled(PrescriptionScreenHintDemoModeActivated) } answers { false } - } - - private fun instantiateViewModel() { - viewModel = PrescriptionViewModel( - prescriptionUseCase = prescriptionUseCase, - profilesUseCase = profilesUseCase, - settingsUseCase = settingsUseCase, - demoUseCase = demoUseCase, - dispatchProvider = coroutineRule.testDispatchProvider, - hintUseCase = hintUseCase, - authenticationUseCase = authenticationUseCase - ) - } - - @Test - fun `if demo is inactive and no security setting is selected - app security hint should be visible`() = - coroutineRule.testDispatcher.runBlockingTest { - every { settingsUseCase.settings } answers { - flowOf( - Settings( - authenticationMethod = SettingsAuthenticationMethod.Unspecified, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - every { demoUseCase.authTokenReceived } answers { MutableStateFlow(false) } - - instantiateViewModel() - - viewModel.screenState().first().hints.let { hints -> - assertTrue( - hints.first() == PrescriptionScreenHintDefineSecurity - ) - } - } - - @Test - fun `if demo is active and no security setting is selected - welcome to demoMode should be at first place`() = - coroutineRule.testDispatcher.runBlockingTest { - every { settingsUseCase.settings } answers { - flowOf( - Settings( - authenticationMethod = SettingsAuthenticationMethod.Unspecified, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - - every { demoUseCase.isDemoModeActive } answers { true } - every { demoUseCase.demoModeActive } answers { MutableStateFlow(true) } - every { demoUseCase.authTokenReceived } answers { MutableStateFlow(false) } - - instantiateViewModel() - - viewModel.screenState().first().hints.let { hints -> - assertTrue( - hints.first() == PrescriptionScreenHintDemoModeActivated - ) - } - } - - @Test - fun `if demo is inactive and some auth is selected - app auth hint should be gone and link to demo mode should be in first place`() = - coroutineRule.testDispatcher.runBlockingTest { - every { settingsUseCase.settings } answers { - flowOf( - Settings( - authenticationMethod = SettingsAuthenticationMethod.None, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - - instantiateViewModel() - - viewModel.screenState().first().hints.let { hints -> - assertTrue( - hints.first() == PrescriptionScreenHintTryDemoMode - ) - - assertNull( - hints.find { it == PrescriptionScreenHintDefineSecurity } - ) - } - } - - @Test - fun `if demo mode was already activated - demo hint is not available anymore`() = - coroutineRule.testDispatcher.runBlockingTest { - every { settingsUseCase.settings } answers { - flowOf( - Settings( - authenticationMethod = SettingsAuthenticationMethod.None, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - every { demoUseCase.demoModeHasBeenSeen } answers { true } - - instantiateViewModel() - - assertNull( - viewModel.screenState() - .first().hints.find { it == PrescriptionScreenHintTryDemoMode } - ) - } - - @Test - fun `if demo mode was canceled - demo hint is not available anymore`() = - coroutineRule.testDispatcher.runBlockingTest { - every { settingsUseCase.settings } answers { - flowOf( - Settings( - authenticationMethod = SettingsAuthenticationMethod.None, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - - every { hintUseCase.cancelledHints } returns flowOf( - setOf( - PrescriptionScreenHintTryDemoMode - ) - ) - every { hintUseCase.isHintCanceled(PrescriptionScreenHintTryDemoMode) } answers { true } - - instantiateViewModel() - - assertNull( - viewModel.screenState() - .first().hints.find { it == PrescriptionScreenHintTryDemoMode } - ) - } - - @Test - fun `if demo mode is active and welcome hint has been canceled - welcome to demo hint is not available anymore`() = - coroutineRule.testDispatcher.runBlockingTest { - every { settingsUseCase.settings } answers { - flowOf( - Settings( - authenticationMethod = SettingsAuthenticationMethod.None, - authenticationFails = 0, - zoomEnabled = false - ) - ) - } - - every { demoUseCase.demoModeActive } answers { MutableStateFlow(true) } - every { hintUseCase.cancelledHints } returns flowOf( - setOf( - PrescriptionScreenHintDemoModeActivated - ) - ) - - instantiateViewModel() - - assertNull( - viewModel.screenState() - .first().hints.find { it == PrescriptionScreenHintDemoModeActivated } - ) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionViewModelTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionViewModelTest.kt deleted file mode 100644 index 2aa966d4..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/ScanPrescriptionViewModelTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.ui - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import de.gematik.ti.erp.app.prescription.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.validScannedCode -import de.gematik.ti.erp.app.utils.validScannedCode2 -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.every -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@ExperimentalCoroutinesApi -class ScanPrescriptionViewModelTest { - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private lateinit var viewModel: ScanPrescriptionViewModel - - @MockK - private lateinit var useCase: PrescriptionUseCase - - @MockK - private lateinit var validator: TwoDCodeValidator - - @MockK - private lateinit var scanner: TwoDCodeScanner - - @MockK - private lateinit var processor: TwoDCodeProcessor - - private val batch = TwoDCodeScanner.Batch( - matrixCodes = listOf(), - cameraSize = android.util.Size(0, 0), - cameraRotation = 0, - averageScanTime = 250 - ) - - @Before - fun setup() { - MockKAnnotations.init(this) - - every { scanner.batch } returns MutableStateFlow(batch) - - viewModel = ScanPrescriptionViewModel( - useCase, - scanner, - processor, - validator, - coroutineRule.testDispatchProvider - ) - } - - @Test - fun `test addScannedCode with three tasks and one duplicated - should return true`() = - coroutineRule.testDispatcher.runBlockingTest { - coEvery { useCase.getAllTasksWithTaskIdOnly() } returns mutableListOf("234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc") - val codeHasUniqueUrls = viewModel.addScannedCode(validScannedCode) - assertTrue("codeHasUniqueUrls", codeHasUniqueUrls) - } - - @Test - fun `test addScannedCode with one task duplicated - should return false`() = - coroutineRule.testDispatcher.runBlockingTest { - coEvery { useCase.getAllTasksWithTaskIdOnly() } returns mutableListOf("234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc") - val codeHasUniqueUrls = viewModel.addScannedCode(validScannedCode2) - assertFalse("codeHasUniqueUrls", codeHasUniqueUrls) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidatorTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidatorTest.kt index 0a33bfae..b6914aaf 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidatorTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/prescription/ui/TwoDCodeValidatorTest.kt @@ -19,13 +19,12 @@ package de.gematik.ti.erp.app.prescription.ui import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import de.gematik.ti.erp.app.di.ApplicationModule -import de.gematik.ti.erp.app.utils.CoroutineTestRule +import de.gematik.ti.erp.app.CoroutineTestRule import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test -import java.time.OffsetDateTime +import java.time.Instant class TwoDCodeValidatorTest { @get:Rule @@ -42,7 +41,7 @@ class TwoDCodeValidatorTest { " \"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea\"\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ) private val scannedTask3 = ScannedCode( @@ -53,7 +52,7 @@ class TwoDCodeValidatorTest { " \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\"\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ) private val scannedTask4 = ScannedCode( @@ -65,7 +64,7 @@ class TwoDCodeValidatorTest { " \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\"\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ) private val notWellFormatted = ScannedCode( @@ -75,7 +74,7 @@ class TwoDCodeValidatorTest { " \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\",\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ) private val emptyUrls = ScannedCode( @@ -83,7 +82,7 @@ class TwoDCodeValidatorTest { " \"urls\": [\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ) private val checkedTask1 = ValidScannedCode( @@ -93,10 +92,10 @@ class TwoDCodeValidatorTest { " \"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea\"\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ), mutableListOf( - "Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea", + "Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea" ) ) @@ -109,7 +108,7 @@ class TwoDCodeValidatorTest { " \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\"\n" + " ]\n" + "}", - OffsetDateTime.now() + Instant.now() ), mutableListOf( "Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea", @@ -120,7 +119,7 @@ class TwoDCodeValidatorTest { @Before fun setup() { - validator = TwoDCodeValidator(ApplicationModule.providesMoshi()) + validator = TwoDCodeValidator() } @Test diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProductionTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProductionTest.kt deleted file mode 100644 index 9a3c5406..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProductionTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.prescription.usecase - -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.prescription.repository.Mapper -import de.gematik.ti.erp.app.prescription.repository.PrescriptionRepository -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.TEST_TASK_GROUP_SCANNED -import de.gematik.ti.erp.app.utils.TEST_TASK_GROUP_SYNCED -import de.gematik.ti.erp.app.utils.testTasks -import de.gematik.ti.erp.app.utils.validScannedCode -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.every -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toCollection -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.time.OffsetDateTime -import kotlinx.coroutines.flow.flow - -@ExperimentalCoroutinesApi -class PrescriptionUseCaseProductionTest { - - private lateinit var useCase: PrescriptionUseCaseProduction - - @MockK - lateinit var repo: PrescriptionRepository - - @MockK - lateinit var mapper: Mapper - - @MockK - lateinit var profilesUseCase: ProfilesUseCase - - @get:Rule - val coroutineRule = CoroutineTestRule() - - @Before - fun setup() { - MockKAnnotations.init(this) - - useCase = PrescriptionUseCaseProduction(repo, mapper, profilesUseCase) - - every { repo.tasks(any()) } answers { flowOf(testTasks()) } - every { repo.syncedTasksWithoutBundle(any()) } answers { flowOf(testTasks().filter { it.scannedOn == null }) } - every { repo.scannedTasksWithoutBundle(any()) } answers { flowOf(testTasks().filter { it.scannedOn != null }) } - every { profilesUseCase.activeProfileName() } returns flow { emit("Tester") } - } - - @Test - fun `tasks - should return every task`() = - coroutineRule.testDispatcher.runBlockingTest { - useCase.tasks().toCollection(mutableListOf()).first().let { - val expectedTasks = (TEST_TASK_GROUP_SCANNED + TEST_TASK_GROUP_SYNCED).sortedArray() - assertEquals(expectedTasks.size, it.size) - assertArrayEquals(expectedTasks, it.map { it.taskId }.sorted().toTypedArray()) - } - } - - @Test - fun `syncedTasks - should only return synced tasks`() = - coroutineRule.testDispatcher.runBlockingTest { - useCase.syncedTasks().toCollection(mutableListOf()).first().let { - val expectedTasks = TEST_TASK_GROUP_SYNCED.sortedArray() - assertEquals(expectedTasks.size, it.size) - assertArrayEquals(expectedTasks, it.map { it.taskId }.sorted().toTypedArray()) - } - } - - @Test - fun `scannedTasks - should only return scanned tasks`() = - coroutineRule.testDispatcher.runBlockingTest { - useCase.scannedTasks().toCollection(mutableListOf()).first().let { - val expectedTasks = TEST_TASK_GROUP_SCANNED.sortedArray() - assertEquals(expectedTasks.size, it.size) - assertArrayEquals(expectedTasks, it.map { it.taskId }.sorted().toTypedArray()) - } - } - - @Test - fun `edit scanPrescriptionsName`() = - coroutineRule.testDispatcher.runBlockingTest { - val scanSessionEnd = OffsetDateTime.now() - every { repo.updateScanSessionName(null, scanSessionEnd) } answers {} - useCase.editScannedPrescriptionsName("", scanSessionEnd) - useCase.editScannedPrescriptionsName(" ", scanSessionEnd) - every { repo.updateScanSessionName("Dr. Test", scanSessionEnd) } answers {} - useCase.editScannedPrescriptionsName(" Dr. Test ", scanSessionEnd) - } - - @Test - fun `test saveToDatabase() with three tasks`() = coroutineRule.testDispatcher.runBlockingTest { - val capTasks = mutableListOf>() - coEvery { useCase.saveScannedTasks(capture(capTasks)) } coAnswers { } - useCase.mapScannedCodeToTask(listOf(validScannedCode)) - - val tasks = capTasks.first() - - assertEquals( - "234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc", - tasks[0].taskId - ) - assertEquals( - "2aef43b8c5e8f2d3d7aef64598b3c40e1d9e348f75d62fd39fe4a7bc5c923de8", - tasks[1].taskId - ) - assertEquals( - "5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189", - tasks[2].taskId - ) - - assertEquals( - "777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea", - tasks[0].accessCode - ) - assertEquals( - "0936cfa582b447144b71ac89eb7bb83a77c67c99d4054f91ee3703acf5d6a629", - tasks[1].accessCode - ) - assertEquals( - "d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5", - tasks[2].accessCode - ) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseTest.kt index 66822f68..a79906ad 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseTest.kt @@ -18,8 +18,8 @@ package de.gematik.ti.erp.app.prescription.usecase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.testRedeemedTasksOrdered +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.utils.testRedeemedTaskIdsOrdered import de.gematik.ti.erp.app.utils.testScannedTasks import de.gematik.ti.erp.app.utils.testScannedTasksOrdered import de.gematik.ti.erp.app.utils.testSyncedTasks @@ -30,50 +30,65 @@ import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test +import java.time.LocalDate +import java.time.ZoneOffset import kotlin.test.assertEquals @ExperimentalCoroutinesApi class PrescriptionUseCaseTest { - // necessary for mockk - abstract class TestPrescriptionUseCase : PrescriptionUseCase @get:Rule val coroutineRule = CoroutineTestRule() @MockK(relaxed = true) - lateinit var useCase: TestPrescriptionUseCase + lateinit var useCase: PrescriptionUseCase @Before fun setup() { MockKAnnotations.init(this) - every { useCase.syncedTasks() } answers { flowOf(testSyncedTasks) } - every { useCase.scannedTasks() } answers { flowOf(testScannedTasks) } + every { useCase.syncedTasks("") } answers { flowOf(testSyncedTasks) } + every { useCase.scannedTasks("") } answers { flowOf(testScannedTasks) } - every { useCase.syncedRecipes() } answers { callOriginal() } - every { useCase.scannedRecipes() } answers { callOriginal() } - every { useCase.redeemedPrescriptions() } answers { callOriginal() } + every { useCase.syncedActiveRecipes("", any()) } answers { callOriginal() } + every { useCase.scannedActiveRecipes("") } answers { callOriginal() } + every { useCase.redeemedPrescriptions("", any()) } answers { callOriginal() } } @Test fun `syncedRecipes - should return synchronized tasks in form of recipes sorted by authoredOn and grouped by organization`() = - coroutineRule.testDispatcher.runBlockingTest { - assertEquals(testSyncedTasksOrdered.map { it.taskId }, useCase.syncedRecipes().first().map { it.taskId }) + runTest { + assertEquals( + testSyncedTasksOrdered.map { it.taskId }, + useCase.syncedActiveRecipes( + profileId = "", + now = LocalDate.parse("2021-02-01").atStartOfDay().toInstant(ZoneOffset.UTC) + ).first().map { it.taskId } + ) } @Test fun `scannedRecipes - should return scanned tasks in form of recipes sorted by scanSessionEnd`() = - coroutineRule.testDispatcher.runBlockingTest { - assertEquals(testScannedTasksOrdered.map { it.taskId }, useCase.scannedRecipes().first().map { it.taskId }) + runTest { + assertEquals( + testScannedTasksOrdered.map { it.taskId }, + useCase.scannedActiveRecipes("").first().map { it.taskId } + ) } @Test fun `redeemed recipes - should return redeemed tasks ordered by redeemedOn`() = - coroutineRule.testDispatcher.runBlockingTest { - assertEquals(testRedeemedTasksOrdered.map { it.taskId }, useCase.redeemedPrescriptions().first().map { it.taskId }) + runTest { + assertEquals( + testRedeemedTaskIdsOrdered, + useCase.redeemedPrescriptions( + profileId = "", + now = LocalDate.parse("2021-02-01").atStartOfDay().toInstant(ZoneOffset.UTC) + ).first().map { it.taskId } + ) } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCaseTest.kt index 862c9804..a959129d 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCaseTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/profiles/usecase/ProfilesUseCaseTest.kt @@ -18,98 +18,81 @@ package de.gematik.ti.erp.app.profiles.usecase -import de.gematik.ti.erp.app.db.entities.ActiveProfile -import de.gematik.ti.erp.app.db.entities.ProfileEntity -import de.gematik.ti.erp.app.db.entities.ProfileColorNames +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.idp.repository.SingleSignOnToken +import de.gematik.ti.erp.app.profiles.model.ProfilesData import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository -import de.gematik.ti.erp.app.utils.CoroutineTestRule +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +import de.gematik.ti.erp.app.protocol.repository.AuditEventsRepository import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.every +import io.mockk.coVerify import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import junit.framework.Assert.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test -import java.time.Instant +import kotlin.test.assertFails @ExperimentalCoroutinesApi class ProfilesUseCaseTest { - - private val expectedProfiles = listOf( - ProfileEntity(name = "Tester", color = ProfileColorNames.TREE), - ProfileEntity(name = "Tester1", color = ProfileColorNames.PINK), - ProfileEntity(name = "Tester2", color = ProfileColorNames.SPRING_GRAY), - ProfileEntity(name = "Tester3", color = ProfileColorNames.SUN_DEW) - ) - private val expectedProfile = ProfileEntity(id = 2, name = "Tester2", color = ProfileColorNames.SPRING_GRAY) - private val expectedActiveProfile = ActiveProfile(profileName = "Tester2") - private lateinit var profilesUseCase: ProfilesUseCase - @MockK + @MockK(relaxed = true) lateinit var profilesRepository: ProfilesRepository @MockK lateinit var idpRepository: IdpRepository + @MockK + lateinit var auditEventsRepository: AuditEventsRepository + @get:Rule val coroutineRule = CoroutineTestRule() + val profile = ProfilesUseCaseData.Profile( + id = "1234567890", + name = "Test", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.PINK, + lastAuthenticated = null, + ssoTokenScope = null, + personalizedImage = null, + avatarFigure = ProfilesData.AvatarFigure.PersonalizedImage + ) + @Before fun setup() { MockKAnnotations.init(this) - val ssoToken = mockk() - every { ssoToken.isValid(any()) } returns true - every { ssoToken.validOn } returns Instant.now().plusSeconds(1000) - - every { profilesRepository.profiles() } returns flowOf(expectedProfiles) - every { profilesRepository.activeProfile() } returns flowOf(expectedActiveProfile) - every { profilesRepository.getProfileById(2) } returns flowOf(expectedProfile) - coEvery { profilesRepository.updateLastAuthenticated(any(), any()) } answers {} - coEvery { idpRepository.getSingleSignOnToken(any()) } returns flowOf(ssoToken) - coEvery { idpRepository.decryptedAccessToken(any()) } returns flowOf("") - - profilesUseCase = ProfilesUseCase(profilesRepository, idpRepository, coroutineRule.testDispatchProvider) + profilesUseCase = ProfilesUseCase( + profilesRepository = profilesRepository, + idpRepository = idpRepository, + auditRepository = auditEventsRepository + ) } @Test - fun `profiles - should return list of four profiles`() = - coroutineRule.testDispatcher.runBlockingTest { - profilesUseCase.profiles.first().let { - assertEquals(expectedProfiles.size, it.size) - } - } + fun `update profile name - should sanitize new name`() = runTest { + profilesUseCase.updateProfileName(profile.id, " T es t ") - @Test - fun `active profile name - should return tester 2`() = - coroutineRule.testDispatcher.runBlockingTest { - profilesUseCase.activeProfileName().first().let { - assertEquals(expectedActiveProfile.profileName, it) - } - } + coVerify(exactly = 1) { profilesRepository.updateProfileName(profile.id, "T es t") } + } @Test - fun `active profile - should return expected active profile`() = - coroutineRule.testDispatcher.runBlockingTest { - profilesUseCase.activeProfile().first().let { - assertEquals(expectedActiveProfile, it) - } + fun `update profile with empty name - should not update name`() = runTest { + assertFails { + profilesUseCase.updateProfileName(profile.id, "") } + coVerify(exactly = 0) { profilesRepository.updateProfileName(any(), any()) } + } + @Test - fun `get profile by id (2) - should return expected profile (2)`() = - coroutineRule.testDispatcher.runBlockingTest { - profilesUseCase.getProfileById(2).first().let { - assertEquals(expectedProfile, it) - } + fun `replace last profile`() = runTest { + assertFails { + profilesUseCase.removeAndSaveProfile(profile, "") } + } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCaseTest.kt b/android/src/test/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCaseTest.kt new file mode 100644 index 00000000..28e2075b --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCaseTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.usecase + +import de.gematik.ti.erp.app.settings.model.SettingsData +import de.gematik.ti.erp.app.settings.model.SettingsData.AppVersion +import de.gematik.ti.erp.app.settings.repository.SettingsRepository +import io.mockk.MockKAnnotations +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import java.time.Instant +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsUseCaseTest { + private lateinit var settings: SettingsUseCase + + @MockK(relaxed = true) + private lateinit var settingsRepository: SettingsRepository + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + + initSettings() + } + + private fun initSettings() { + settings = SettingsUseCase( + context = mockk(), + settingsRepository = settingsRepository + ) + } + + @Test + fun `accept onboarding`() = runTest { + val now = Instant.now() + settings.onboardingSucceeded( + authenticationMode = SettingsData.AuthenticationMode.Unspecified, + "Profil 1", + now = now + ) + + coVerify(exactly = 1) { + settingsRepository.saveOnboardingSucceededData( + SettingsData.AuthenticationMode.Unspecified, + "Profil 1", + now + ) + } + } + + @Test + fun `show data terms update - data accepted in the past`() = runTest { + every { settingsRepository.general } returns flowOf( + SettingsData.General( + latestAppVersion = AppVersion(code = 1, name = "Test"), + onboardingShownIn = null, + dataProtectionVersionAcceptedOn = DATA_PROTECTION_LAST_UPDATED.minusSeconds(1000), + zoomEnabled = false, + userHasAcceptedInsecureDevice = false, + authenticationFails = 0, + welcomeDrawerShown = false + ) + ) + + initSettings() + + assertEquals(true, settings.showDataTermsUpdate.first()) + } + + @Test + fun `show data terms update - data already accepted`() = runTest { + every { settingsRepository.general } returns flowOf( + SettingsData.General( + latestAppVersion = AppVersion(code = 1, name = "Test"), + onboardingShownIn = null, + dataProtectionVersionAcceptedOn = DATA_PROTECTION_LAST_UPDATED.plusSeconds(1000), + zoomEnabled = false, + userHasAcceptedInsecureDevice = false, + authenticationFails = 0, + welcomeDrawerShown = false + ) + ) + + initSettings() + + assertEquals(false, settings.showDataTermsUpdate.first()) + } + + @Test + fun `show welcome drawer`() = runTest { + every { settingsRepository.general } returns flowOf( + SettingsData.General( + latestAppVersion = AppVersion(code = 1, name = "Test"), + onboardingShownIn = null, + dataProtectionVersionAcceptedOn = DATA_PROTECTION_LAST_UPDATED.plusSeconds(1000), + zoomEnabled = false, + userHasAcceptedInsecureDevice = false, + authenticationFails = 0, + welcomeDrawerShown = false + ) + ) + initSettings() + + assertEquals(true, settings.showWelcomeDrawer.first()) + } + + @Test + fun `don't show welcome drawer`() = runTest { + every { settingsRepository.general } returns flowOf( + SettingsData.General( + latestAppVersion = AppVersion(code = 1, name = "Test"), + onboardingShownIn = null, + dataProtectionVersionAcceptedOn = DATA_PROTECTION_LAST_UPDATED.plusSeconds(1000), + zoomEnabled = false, + userHasAcceptedInsecureDevice = false, + authenticationFails = 0, + welcomeDrawerShown = true + ) + ) + initSettings() + + assertEquals(false, settings.showWelcomeDrawer.first()) + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/utils/DateTimeTest.kt b/android/src/test/java/de/gematik/ti/erp/app/utils/DateTimeTest.kt new file mode 100644 index 00000000..577b7764 --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/utils/DateTimeTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.utils + +import de.gematik.ti.erp.app.fhir.parser.asTemporalAccessor +import org.junit.Test +import java.util.Locale +import kotlin.test.assertEquals + +class DateTimeTest { + @Test + fun `format temporal accessor`() { + Locale.setDefault(Locale.GERMAN) + + assertEquals("07.02.2015, 11:28:17", temporalText("2015-02-07T13:28:17+02:00".asTemporalAccessor()!!)) + assertEquals("07.02.2015", temporalText("2015-02-07".asTemporalAccessor()!!)) + assertEquals("Februar 2015", temporalText("2015-02".asTemporalAccessor()!!)) + assertEquals("2015", temporalText("2015".asTemporalAccessor()!!)) + assertEquals("11:28:17", temporalText("11:28:17".asTemporalAccessor()!!)) + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/utils/LiveDataTestUtil.kt b/android/src/test/java/de/gematik/ti/erp/app/utils/LiveDataTestUtil.kt deleted file mode 100644 index 4c7a6f53..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/utils/LiveDataTestUtil.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.utils - -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException - -/** - * Observes a [LiveData] until the `block` is done executing. - */ -fun LiveData.observeForTesting(block: () -> Unit) { - val observer = Observer { } - try { - observeForever(observer) - block() - } finally { - removeObserver(observer) - } -} - -/** - * Gets the value of a [LiveData] or waits for it to have one, with a timeout. - * - * Use this extension from host-side (JVM) tests. It's recommended to use it alongside - * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. - */ -fun LiveData.getOrAwaitValue( - time: Long = 2, - timeUnit: TimeUnit = TimeUnit.SECONDS, - afterObserve: () -> Unit = {} -): T { - var data: T? = null - val latch = CountDownLatch(1) - val observer = object : Observer { - override fun onChanged(o: T?) { - data = o - latch.countDown() - this@getOrAwaitValue.removeObserver(this) - } - } - this.observeForever(observer) - - afterObserve.invoke() - - // Don't wait indefinitely if the LiveData is not set. - if (!latch.await(time, timeUnit)) { - this.removeObserver(observer) - throw TimeoutException("LiveData value was never set.") - } - - @Suppress("UNCHECKED_CAST") - return data as T -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/utils/Proxy.kt b/android/src/test/java/de/gematik/ti/erp/app/utils/Proxy.kt new file mode 100644 index 00000000..24adf3b8 --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/utils/Proxy.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.utils + +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URI + +fun OkHttpClient.Builder.addSystemProxy() = + apply { + System.getenv("https_proxy")?.let { proxy -> + val uri = URI.create(proxy) + proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(uri.host, uri.port))) + } + } diff --git a/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt b/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt index 58fcea19..9f029048 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/utils/TestData.kt @@ -18,515 +18,236 @@ package de.gematik.ti.erp.app.utils -import ca.uhn.fhir.context.FhirContext -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.db.entities.Communication -import de.gematik.ti.erp.app.db.entities.CommunicationProfile -import de.gematik.ti.erp.app.db.entities.Task -import de.gematik.ti.erp.app.idp.api.models.Challenge -import de.gematik.ti.erp.app.idp.api.models.TokenResponse -import de.gematik.ti.erp.app.pharmacy.usecase.model.UIPrescriptionOrder -import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetailScanned -import de.gematik.ti.erp.app.prescription.ui.ScannedCode -import de.gematik.ti.erp.app.prescription.ui.ValidScannedCode -import de.gematik.ti.erp.app.prescription.usecase.createMatrixCode -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.MedicationDispense -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode -import java.io.File -import java.io.IOException -import java.time.LocalDate -import java.time.OffsetDateTime - -const val ASSET_BASE_PATH = "src/test/assets/" - -private val fhirContext by lazy { - FhirContext.forR4() -} - -val TEST_TASK_GROUP_SYNCED = arrayOf( - "Task/5c19492e-1dd2-11b2-803a-63bf44e44fb8", - "Task/a90e60e3-75a2-458d-a027-1539fa612f83", - "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - "Task/2910233f-0ea2-46b7-b174-240a6240de3a" -) -val TEST_TASK_GROUP_SCANNED = arrayOf( - "Task/376ed5ef-7f9a-40de-baf8-593de2417124", - "Task/30aa54cc-a541-4163-811b-0bed57ce7230" -) - -fun testTasks() = listOf( - Task( - taskId = "Task/5c19492e-1dd2-11b2-803a-63bf44e44fb8", - accessCode = "71f62e55a662456195049c59f5c19eb371f62e55a662456195049c59f5c19eb3", - profileName = "Tester", - rawKBVBundle = "{}".toByteArray(), - ), - Task( - taskId = "Task/a90e60e3-75a2-458d-a027-1539fa612f83", - accessCode = "2f5a441e77fc44178f4eea2e6d19a23a2f5a441e77fc44178f4eea2e6d19a23a", - profileName = "Tester", - rawKBVBundle = "{}".toByteArray(), - ), - Task( - taskId = "Task/376ed5ef-7f9a-40de-baf8-593de2417124", - accessCode = "bcc13212c7674cb3bc465a78efe0992ebcc13212c7674cb3bc465a78efe0992e", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:41+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 7, - scanSessionName = null, - profileName = "Tester", - - ), - Task( - taskId = "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - accessCode = "3ea8dc08e5aa4693825437cf73e6d0333ea8dc08e5aa4693825437cf73e6d033", - profileName = "Tester", - rawKBVBundle = "{}".toByteArray(), - ), - Task( - taskId = "Task/30aa54cc-a541-4163-811b-0bed57ce7230", - accessCode = "600dd956fab74e3fb842d5afaee20401600dd956fab74e3fb842d5afaee20401", - scannedOn = OffsetDateTime.parse("2020-12-03T13:40:11+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-03T13:42:41+00:00"), - profileName = "Tester", - nrInScanSession = 1, - scanSessionName = "Some Other Name", - ), - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - profileName = "Tester", - rawKBVBundle = "{}".toByteArray(), - ), -) - -val testSyncedTasks by lazy { - listOf( - Task( - taskId = "Task/a2619fd0-6e48-11ec-90d6-0242ac120003", - accessCode = "71f62e55a662456195049c59f5c19eb371f62e55a662456195049c59f5c19eb3", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Schokolade", - expiresOn = null, - authoredOn = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - profileName = "Tester", - redeemedOn = OffsetDateTime.parse("2020-12-06T14:49:46+00:00"), - ), - Task( - taskId = "Task/a90e60e3-75a2-458d-a027-1539fa612f83", - accessCode = "2f5a441e77fc44178f4eea2e6d19a23a2f5a441e77fc44178f4eea2e6d19a23a", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Bonbons", - expiresOn = null, - authoredOn = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - profileName = "Tester", - redeemedOn = OffsetDateTime.parse("2020-12-05T14:49:46+00:00"), - ), - Task( - taskId = "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - accessCode = "3ea8dc08e5aa4693825437cf73e6d0333ea8dc08e5aa4693825437cf73e6d033", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Gummibärchen", - expiresOn = null, - authoredOn = OffsetDateTime.parse("2020-12-05T09:49:46+00:00"), - profileName = "Tester", - ), - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - organization = "MVZ Haus der vielen Ärzte", - medicationText = "Viel zu viel", - expiresOn = LocalDate.parse("2021-04-01"), - authoredOn = OffsetDateTime.parse("2020-12-20T09:49:46+00:00"), - profileName = "Tester", - ), - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - organization = "MVZ Haus der vielen Ärzte", - medicationText = "Viel zu viel", - expiresOn = LocalDate.parse("2021-03-05"), - authoredOn = OffsetDateTime.parse("2020-12-04T09:49:46+00:00"), - profileName = "Tester", - ), +import de.gematik.ti.erp.app.prescription.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import java.time.Duration +import java.time.Instant +import java.util.UUID + +fun syncedTask( + taskId: String = "Task/" + UUID.randomUUID().toString(), + accessCode: String = UUID.randomUUID().toString(), + lastModified: Instant, + organizationName: String?, + practitionerName: String?, + expiresOn: Instant?, + acceptUntil: Instant?, + authoredOn: Instant, + status: SyncedTaskData.TaskStatus, + medicationName: String, + medicationDispenseWhenHandedOver: Instant? = null +) = + SyncedTaskData.SyncedTask( + profileId = "", + taskId = taskId, + isIncomplete = false, + pvsIdentifier = "123456", + accessCode = accessCode, + lastModified = lastModified, + organization = SyncedTaskData.Organization( + name = organizationName, + address = null, + uniqueIdentifier = null, + phone = null, + mail = null + ), + practitioner = SyncedTaskData.Practitioner( + name = practitionerName, + qualification = null, + practitionerIdentifier = null + ), + patient = SyncedTaskData.Patient( + name = null, + address = null, + birthdate = null, + insuranceIdentifier = null + ), + insuranceInformation = SyncedTaskData.InsuranceInformation( + name = null, + status = null + ), + expiresOn = expiresOn, + acceptUntil = acceptUntil, + authoredOn = authoredOn, + status = status, + medicationRequest = SyncedTaskData.MedicationRequest( + medication = SyncedTaskData.MedicationPZN( + category = SyncedTaskData.MedicationCategory.ARZNEI_UND_VERBAND_MITTEL, + vaccine = false, + text = medicationName, + form = null, + lotNumber = null, + expirationDate = null, + uniqueIdentifier = "", + normSizeCode = null, + amount = SyncedTaskData.Ratio( + numerator = SyncedTaskData.Quantity( + value = "", + unit = "" + ), + denominator = null + ) + ), + dateOfAccident = null, + location = null, + emergencyFee = null, + substitutionAllowed = false, + dosageInstruction = null, + note = "", + multiplePrescriptionInfo = SyncedTaskData.MultiplePrescriptionInfo() + ), + medicationDispenses = if (medicationDispenseWhenHandedOver != null) { + listOf( + SyncedTaskData.MedicationDispense( + dispenseId = null, + patientIdentifier = "", + medication = null, + wasSubstituted = false, + dosageInstruction = "", + performer = "", + whenHandedOver = medicationDispenseWhenHandedOver + ) + ) + } else { + emptyList() + }, + communications = listOf(), + failureToReport = "abcdefg" ) -} -val testScannedTasks by lazy { +val testSyncedTasks = listOf( - Task( - taskId = "Task/5c19492e-1dd2-11b2-803a-63bf44e44fb8", - accessCode = "71f62e55a662456195049c59f5c19eb371f62e55a662456195049c59f5c19eb3", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:36+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 0, - scanSessionName = "Foo", - profileName = "Tester", - redeemedOn = OffsetDateTime.parse("2020-12-05T14:49:46+00:00"), - ), - Task( - taskId = "Task/a90e60e3-75a2-458d-a027-1539fa612f83", - accessCode = "2f5a441e77fc44178f4eea2e6d19a23a2f5a441e77fc44178f4eea2e6d19a23a", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:37+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 1, - scanSessionName = null, - profileName = "Tester", - ), - Task( - taskId = "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - accessCode = "3ea8dc08e5aa4693825437cf73e6d0333ea8dc08e5aa4693825437cf73e6d033", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:41+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 2, - scanSessionName = null, - profileName = "Tester", - ), - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - scannedOn = OffsetDateTime.parse("2020-12-03T13:40:11+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-03T13:42:41+00:00"), - nrInScanSession = 0, - scanSessionName = "Some Name", - profileName = "Tester", - ), + // 0 + syncedTask( + lastModified = Instant.parse("2020-12-06T14:49:46Z"), + organizationName = null, + practitionerName = "Praxis Glücklicher gehts nicht", + expiresOn = Instant.parse("2020-12-02T14:49:46Z") + Duration.ofDays(3 * 28), + acceptUntil = Instant.parse("2020-12-02T14:49:46Z") + Duration.ofDays(28), + authoredOn = Instant.parse("2020-12-02T14:49:46Z"), + status = SyncedTaskData.TaskStatus.Completed, + medicationName = "Schokolade" + ), + // 1 + syncedTask( + lastModified = Instant.parse("2020-12-05T14:49:46Z"), + organizationName = null, + practitionerName = "Praxis Glücklicher gehts nicht", + expiresOn = Instant.parse("2020-12-02T22:49:46Z") + Duration.ofDays(3 * 28), + acceptUntil = Instant.parse("2020-12-02T14:49:46Z") + Duration.ofDays(28), + authoredOn = Instant.parse("2020-12-02T14:49:46Z"), + status = SyncedTaskData.TaskStatus.Completed, + medicationName = "Bonbons" + ), + // 2 + syncedTask( + lastModified = Instant.parse("2020-12-05T09:49:46Z"), + organizationName = null, + practitionerName = "Praxis Glücklicher gehts nicht", + expiresOn = Instant.parse("2020-12-02T14:49:46Z") + Duration.ofDays(3 * 28), + acceptUntil = Instant.parse("2020-12-02T14:49:46Z") + Duration.ofDays(28), + authoredOn = Instant.parse("2020-12-05T09:49:46Z"), + status = SyncedTaskData.TaskStatus.Ready, + medicationName = "Gummibärchen" + ), + // 3 + syncedTask( + lastModified = Instant.parse("2020-12-20T09:49:46Z"), + organizationName = "MVZ Haus der vielen Ärzte", + practitionerName = null, + expiresOn = Instant.parse("2020-12-20T09:49:46Z") + Duration.ofDays(3 * 28), + acceptUntil = Instant.parse("2020-12-20T09:49:46Z") + Duration.ofDays(28), + authoredOn = Instant.parse("2020-12-20T09:49:46Z"), + status = SyncedTaskData.TaskStatus.Ready, + medicationName = "Viel zu viel" + ), + // 4 + syncedTask( + lastModified = Instant.parse("2020-12-04T09:49:46Z"), + organizationName = "MVZ Haus der vielen Ärzte", + practitionerName = null, + expiresOn = Instant.parse("2020-12-04T09:49:46Z") + Duration.ofDays(3 * 28), + acceptUntil = Instant.parse("2020-12-04T09:49:46Z") + Duration.ofDays(28), + authoredOn = Instant.parse("2020-12-04T09:49:46Z"), + status = SyncedTaskData.TaskStatus.Ready, + medicationName = "Viel zu viel" + ) ) -} -val testSyncedTasksOrdered by lazy { - listOf( - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - organization = "MVZ Haus der vielen Ärzte", - medicationText = "Viel zu viel", - expiresOn = LocalDate.parse("2021-04-01"), - authoredOn = OffsetDateTime.parse("2020-12-20T09:49:46+00:00"), - profileName = "Tester", - ), - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - organization = "MVZ Haus der vielen Ärzte", - medicationText = "Viel zu viel", - expiresOn = LocalDate.parse("2021-03-05"), - authoredOn = OffsetDateTime.parse("2020-12-04T09:49:46+00:00"), - profileName = "Tester", - ), - Task( - taskId = "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - accessCode = "3ea8dc08e5aa4693825437cf73e6d0333ea8dc08e5aa4693825437cf73e6d033", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Gummibärchen", - expiresOn = null, - authoredOn = OffsetDateTime.parse("2020-12-05T09:49:46+00:00"), - profileName = "Tester", - ), +fun scannedTask( + taskId: String = "Task/" + UUID.randomUUID().toString(), + accessCode: String = UUID.randomUUID().toString(), + scannedOn: Instant, + redeemedOn: Instant?, + sentOn: Instant? +) = + ScannedTaskData.ScannedTask( + profileId = "", + taskId = taskId, + accessCode = accessCode, + scannedOn = scannedOn, + redeemedOn = redeemedOn ) -} -val testScannedTasksOrdered by lazy { +val testScannedTasks = listOf( - Task( - taskId = "Task/2910233f-0ea2-46b7-b174-240a6240de3a", - accessCode = "82de8475f352482dbd602972c6024c6a82de8475f352482dbd602972c6024c6a", - scannedOn = OffsetDateTime.parse("2020-12-03T13:40:11+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-03T13:42:41+00:00"), - nrInScanSession = 0, - scanSessionName = "Some Name", - profileName = "Tester", - ), - Task( - taskId = "Task/a90e60e3-75a2-458d-a027-1539fa612f83", - accessCode = "2f5a441e77fc44178f4eea2e6d19a23a2f5a441e77fc44178f4eea2e6d19a23a", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:37+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 1, - scanSessionName = null, - profileName = "Tester", - ), - Task( - taskId = "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - accessCode = "3ea8dc08e5aa4693825437cf73e6d0333ea8dc08e5aa4693825437cf73e6d033", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:41+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 2, - scanSessionName = null, - profileName = "Tester", - ), + // 0 + scannedTask( + scannedOn = Instant.parse("2020-12-02T14:48:36Z"), + redeemedOn = Instant.parse("2020-12-05T14:49:47Z"), + sentOn = null + ), + // 1 + scannedTask( + scannedOn = Instant.parse("2020-12-02T14:48:37Z"), + redeemedOn = null, + sentOn = null + ), + // 2 + scannedTask( + scannedOn = Instant.parse("2020-12-02T14:48:41Z"), + redeemedOn = null, + sentOn = null + ), + // 3 + scannedTask( + scannedOn = Instant.parse("2020-12-03T13:40:11Z"), + redeemedOn = null, + sentOn = null + ) ) -} -val testRedeemedTasksOrdered by lazy { +// keep in sync with `testSyncedTasks` +val testSyncedTasksOrdered = listOf( - Task( - taskId = "Task/a2619fd0-6e48-11ec-90d6-0242ac120003", - accessCode = "71f62e55a662456195049c59f5c19eb371f62e55a662456195049c59f5c19eb3", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Schokolade", - expiresOn = null, - authoredOn = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - profileName = "Tester", - redeemedOn = OffsetDateTime.parse("2020-12-06T14:49:46+00:00"), - ), - Task( - taskId = "Task/5c19492e-1dd2-11b2-803a-63bf44e44fb8", - accessCode = "71f62e55a662456195049c59f5c19eb371f62e55a662456195049c59f5c19eb3", - scannedOn = OffsetDateTime.parse("2020-12-02T14:48:36+00:00"), - scanSessionEnd = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - nrInScanSession = 0, - scanSessionName = "Foo", - profileName = "Tester", - redeemedOn = OffsetDateTime.parse("2020-12-05T14:49:46+00:00"), - ), - Task( - taskId = "Task/a90e60e3-75a2-458d-a027-1539fa612f83", - accessCode = "2f5a441e77fc44178f4eea2e6d19a23a2f5a441e77fc44178f4eea2e6d19a23a", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Bonbons", - expiresOn = null, - authoredOn = OffsetDateTime.parse("2020-12-02T14:49:46+00:00"), - profileName = "Tester", - redeemedOn = OffsetDateTime.parse("2020-12-05T14:49:46+00:00"), - ), - ) -} - -fun detailPrescriptionScanned(scannedOn: OffsetDateTime = OffsetDateTime.now()) = - UIPrescriptionDetailScanned( - taskId = "4711", - redeemedOn = OffsetDateTime.now(), - "accessCode", - testMatrix(), - 1, - scannedOn, - unRedeemMorePossible = true + testSyncedTasks[2], + testSyncedTasks[4], + testSyncedTasks[3] ) -fun testMatrix() = BitMatrixCode(createMatrixCode("Task/$4711/\$accept?ac=accessCode")) - -fun challenge(challenge: String): Challenge? { - val moshi = Moshi.Builder().build() - val adapter = moshi.adapter(Challenge::class.java) - return adapter.fromJson(challenge) -} - -fun tokenResponse(tokenResponse: String): TokenResponse? { - val moshi = Moshi.Builder().build() - val adapter = moshi.adapter(TokenResponse::class.java) - return adapter.fromJson(tokenResponse) -} - -// -// fun testUIData() = UIPrescription("foo", ZonedDateTime.now()) -// -// fun testPatient(): List { -// val patient = Patient() -// patient.addName(HumanName().setFamily("foo")) -// patient.birthDate = Date() -// return listOf(patient) -// } -// -// fun testUIPrescriptions() = listOf(UIPrescription("foo", ZonedDateTime.now())) -// fun testMedications() = listOf( -// testMedication(), -// testMedication() -// ) -// -// fun testScanTasks() = listOf( -// ScanTask( -// "id", -// "accessCode", -// LocalDateTime.now(), -// LocalDateTime.now(), -// null -// ) -// ) -// -// fun testMedication(): Medication = -// Medication( -// id = "42", -// OffsetDateTime.now(), -// OffsetDateTime.now(), -// "someNote", -// "prescriptionId", -// "medicationText", -// "taskId", -// "practitionerId", -// "patientId" -// ) -// -// fun getPractitionerMedications(): List { -// val groupSize = 3 -// val prescriptionGroups = ArrayList(groupSize) -// for (group in 0 until groupSize) { -// val variousCountOfPrescriptions = (Math.random() * 6).toInt() + 1 -// val medications = -// ArrayList(variousCountOfPrescriptions) -// for (i in 0 until variousCountOfPrescriptions) { -// medications.add( -// Medication( -// "some Id", -// OffsetDateTime.now().plusDays(-((group + 1) + group * group).toLong()), -// OffsetDateTime.now().plusDays(i.toLong()), -// null, -// "some id", -// "Ganz tolles Medikament $i", -// (Math.random() * 1000).toString(), -// "some id", -// "some id" -// ) -// ) -// } -// prescriptionGroups.add( -// PractitionerAndMedications( -// Practitioner( -// "some id", -// "Hans-Peter", -// "von Glücklich am See $group", -// "Dr. Dr. med", -// ), -// medications -// ) -// ) -// } -// return prescriptionGroups -// } - -fun getBundleFromAssetFileName(filename: String): Bundle { - val parser = fhirContext.newJsonParser() - val jsonAsString = readJsonFile(filename) - return parser.parseResource(jsonAsString) as Bundle -} - -fun getMedicationDispenseFromAssetFileName(filename: String): MedicationDispense { - val parser = fhirContext.newJsonParser() - val jsonAsString = readJsonFile(filename) - return parser.parseResource(jsonAsString) as MedicationDispense -} - -fun testBundle(): Bundle { - return getBundleFromAssetFileName("task_bundle.json") -} - -fun testCommunicationBundle(): Bundle { - return getBundleFromAssetFileName("communication_bundle.json") -} - -fun testSingleKBVBundle(): Bundle { - return getBundleFromAssetFileName("kbv_bundle.json") -} - -fun taskWithoutKBVBundle(): Bundle { - return getBundleFromAssetFileName("task_without_kbv_bundle.json") -} - -fun taskWithBundle(): Bundle { - return getBundleFromAssetFileName("task_with_bundle_response.json") -} - -fun taskWithDirectAssignmentWithoutKBVBundle(): Bundle { - return getBundleFromAssetFileName("task_with_direct_assignment_without_kbv_bundle.json") -} - -fun allAuditEvents(): Bundle { - return getBundleFromAssetFileName("audit_event_dev.json") -} - -fun emptyAuditEvents(): Bundle { - return getBundleFromAssetFileName("empty_audit_event_dev.json") -} - -fun testPharmacySearchBundle(): Bundle { - val parser = fhirContext.newJsonParser() - val jsonAsString = readJsonFile("pharmacy_result_bundle.json") - return parser.parseResource(jsonAsString) as Bundle -} - -fun emptyTestBundle(): Bundle { - val parser = fhirContext.newJsonParser() - val jsonAsString = "{'resourceType': 'Bundle', 'type': 'collection'}" - return parser.parseResource(jsonAsString) as Bundle -} - -fun testMedicationDispenseBundle(): MedicationDispense { - return getMedicationDispenseFromAssetFileName("medication_dispense.json") -} - -fun listOfUIPrescriptions() = - listOf(testUIPrescription()) - -fun testUIPrescription() = UIPrescriptionOrder("taskId", "title", false, "accessCode") - -fun communicationShipment() = - Communication( - "id", - CommunicationProfile.ErxCommunicationReply, - profileName = "Tester", - "time", - "taskId", - "telematiksId", - "kbvUserId", - "{\"version\": \"1\",\"supplyOptionsType\": \"shipment\",\"info_text\": \"Wir möchten Sie informieren, dass Ihre bestellten Medikamente versandt wurde!\",\"url\": \"das-e-rezept-fuer-deutschland.de\"}", - false - ) - -fun communicationDelivery() = - Communication( - "id", - CommunicationProfile.ErxCommunicationReply, - profileName = "Tester", - "time", - "taskId", - "telematiksId", - "kbvUserId", - "{\"version\": \"1\",\"supplyOptionsType\": \"delivery\",\"info_text\": \"\"}", - false - ) - -fun errorCommunicationDelivery() = - Communication( - "id", - CommunicationProfile.ErxCommunicationReply, - profileName = "Tester", - "time", - "taskId", - "telematiksId", - "kbvUserId", - "this payload is wrong", - false +// keep in sync with `testScannedTasks` +val testScannedTasksOrdered by lazy { + listOf( + testScannedTasks[3], + testScannedTasks[2], + testScannedTasks[1] ) - -@Throws(IOException::class) -fun readJsonFile(filename: String): String { - return File(ASSET_BASE_PATH + filename).readText(Charsets.UTF_8) } -val scannedCode = ScannedCode( - "{\n" + - " \"urls\": [\n" + - " \"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea\",\n" + - " \"Task/2aef43b8c5e8f2d3d7aef64598b3c40e1d9e348f75d62fd39fe4a7bc5c923de8/\$accept?ac=0936cfa582b447144b71ac89eb7bb83a77c67c99d4054f91ee3703acf5d6a629\",\n" + - " \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\"\n" + - " ]\n" + - "}", - OffsetDateTime.now() -) - -val validScannedCode = ValidScannedCode( - scannedCode, - mutableListOf( - "Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea", - "Task/2aef43b8c5e8f2d3d7aef64598b3c40e1d9e348f75d62fd39fe4a7bc5c923de8/\$accept?ac=0936cfa582b447144b71ac89eb7bb83a77c67c99d4054f91ee3703acf5d6a629", - "Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5" +// keep in sync with `testSyncedTasks` +val testRedeemedTasksOrdered = + listOf( + testSyncedTasks[0], + testScannedTasks[0], + testSyncedTasks[1] ) -) -val validScannedCode2 = ValidScannedCode( - scannedCode, - mutableListOf( - "Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea", - ) -) +val testRedeemedTaskIdsOrdered + get() = + testRedeemedTasksOrdered.map { + when (it) { + is ScannedTaskData.ScannedTask -> it.taskId + is SyncedTaskData.SyncedTask -> it.taskId + else -> error("wrong type") + } + } diff --git a/android/src/sharedTest/java/de/gematik/ti/erp/app/vau/TestData.kt b/android/src/test/java/de/gematik/ti/erp/app/vau/TestData.kt similarity index 97% rename from android/src/sharedTest/java/de/gematik/ti/erp/app/vau/TestData.kt rename to android/src/test/java/de/gematik/ti/erp/app/vau/TestData.kt index c26c8b9b..a5bf027d 100644 --- a/android/src/sharedTest/java/de/gematik/ti/erp/app/vau/TestData.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/vau/TestData.kt @@ -18,11 +18,10 @@ package de.gematik.ti.erp.app.vau -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.vau.api.model.OCSPAdapter import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList -import de.gematik.ti.erp.app.vau.api.model.X509Adapter +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okio.ByteString.Companion.decodeBase64 import org.bouncycastle.cert.X509CertificateHolder import org.bouncycastle.jce.provider.BouncyCastleProvider @@ -47,9 +46,6 @@ fun base64X509Certificate(certInBase64: String) = X509CertificateHolder(certInBase64.decodeBase64()!!.toByteArray()) object TestCertificates { - private val moshi = Moshi.Builder().add(OCSPAdapter()).add(X509Adapter()).build() - private val adapterCerts = moshi.adapter(UntrustedCertList::class.java) - private val adapterOCSP = moshi.adapter(UntrustedOCSPList::class.java) object Vau { val OID = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 2) // oid = 1.2.276.0.76.4.258 @@ -75,7 +71,7 @@ object TestCertificates { } """.trimIndent() - val CertList by lazy { adapterCerts.fromJson(JsonCertList)!! } + val CertList: UntrustedCertList by lazy { Json.decodeFromString(JsonCertList) } val ValidTimestamp: Instant = Instant.ofEpochSecond(1615368104) // 2021-03-10T09:21:44.000Z val ExpiredTimestamp: Instant = @@ -157,7 +153,7 @@ object TestCertificates { } /** - * First response of [OCSPList]. + * First response of [OCSP]. */ object OCSP1 { const val Base64 = @@ -172,7 +168,7 @@ object TestCertificates { } /** - * Second response of [OCSPList]. + * Second response of [OCSP]. */ object OCSP2 { const val Base64 = @@ -182,7 +178,7 @@ object TestCertificates { } /** - * Third response of [OCSPList]. + * Third response of [OCSP]. */ object OCSP3 { const val Base64 = @@ -204,7 +200,8 @@ object TestCertificates { * | | * +------------------------+ */ - object OCSPList { + object OCSP { + @Suppress("MaxLineLength") val JsonOCSPList = """ { "OCSP Responses": [ @@ -215,7 +212,7 @@ object TestCertificates { } """.trimIndent() - val OCSPList by lazy { adapterOCSP.fromJson(JsonOCSPList)!! } + val OCSPList: UntrustedOCSPList by lazy { Json.decodeFromString(JsonOCSPList) } } object CA10 { diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreIntegrationTest.kt b/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreIntegrationTest.kt index f7ac7682..6398ba1b 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreIntegrationTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreIntegrationTest.kt @@ -18,33 +18,38 @@ package de.gematik.ti.erp.app.vau -import com.squareup.moshi.Moshi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import de.gematik.ti.erp.app.BuildKonfig -import de.gematik.ti.erp.app.utils.CoroutineTestRule +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.utils.addSystemProxy import de.gematik.ti.erp.app.vau.api.VauService -import de.gematik.ti.erp.app.vau.api.model.OCSPAdapter -import de.gematik.ti.erp.app.vau.api.model.X509Adapter +import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList +import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList import de.gematik.ti.erp.app.vau.repository.VauLocalDataSource import de.gematik.ti.erp.app.vau.repository.VauRemoteDataSource import de.gematik.ti.erp.app.vau.repository.VauRepository -import de.gematik.ti.erp.app.vau.usecase.TrustedTruststoreProvider +import de.gematik.ti.erp.app.vau.usecase.TrustedTruststore import de.gematik.ti.erp.app.vau.usecase.TruststoreConfig -import de.gematik.ti.erp.app.vau.usecase.TruststoreTimeSourceProvider import de.gematik.ti.erp.app.vau.usecase.TruststoreUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor -import org.junit.Assume.assumeTrue -import org.junit.Before +import org.bouncycastle.cert.X509CertificateHolder +import org.junit.Assume import org.junit.Rule -import org.junit.Test import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory +import java.time.Duration +import java.time.Instant +import kotlin.test.BeforeTest +import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class TruststoreIntegrationTest { @@ -54,27 +59,34 @@ class TruststoreIntegrationTest { @MockK lateinit var localDataSource: VauLocalDataSource - private val moshi = Moshi.Builder().add(OCSPAdapter()).add(X509Adapter()).build() + @Suppress("JSON_FORMAT_REDUNDANT") + @OptIn(ExperimentalSerializationApi::class) + private val jsonConverterFactory = Json { + ignoreUnknownKeys = true + encodeDefaults = true + }.asConverterFactory("application/json".toMediaType()) - @Before + @BeforeTest fun setup() { MockKAnnotations.init(this) } + @OptIn(ExperimentalSerializationApi::class) @Test - fun `create truststore from remote source`() { - assumeTrue(BuildKonfig.TEST_RUN_WITH_TRUSTSTORE_INTEGRATION) + fun `create truststore from remote source`() = runTest { + Assume.assumeTrue(BuildKonfig.TEST_RUN_WITH_TRUSTSTORE_INTEGRATION) coEvery { localDataSource.loadUntrusted() } coAnswers { null } coEvery { localDataSource.saveLists(any(), any()) } coAnswers { } coEvery { localDataSource.deleteAll() } coAnswers { } val okhttp = OkHttpClient.Builder() + .addSystemProxy() .addInterceptor( Interceptor { chain -> chain.proceed( chain.request().newBuilder() - .addHeader("User-Agent", "test") + .addHeader("User-Agent", BuildKonfig.USER_AGENT) .addHeader("Accept", "application/json") .build() ) @@ -90,25 +102,31 @@ class TruststoreIntegrationTest { val vauService = Retrofit.Builder() .client(okhttp) .baseUrl(BuildKonfig.BASE_SERVICE_URI) - .addConverterFactory( - MoshiConverterFactory.create( - moshi - ) - ) + .addConverterFactory(jsonConverterFactory) .build() .create(VauService::class.java) val useCase = TruststoreUseCase( - TruststoreConfig(), - VauRepository(localDataSource, VauRemoteDataSource(vauService), coroutineRule.testDispatchProvider), - TruststoreTimeSourceProvider(), - TrustedTruststoreProvider() + TruststoreConfig { return@TruststoreConfig BuildKonfig.APP_TRUST_ANCHOR_BASE64 }, + VauRepository(localDataSource, VauRemoteDataSource(vauService), coroutineRule.dispatchers), + { Instant.now() }, + { untrustedOCSPList: UntrustedOCSPList, + untrustedCertList: UntrustedCertList, + trustAnchor: X509CertificateHolder, + ocspResponseMaxAge: Duration, + timestamp: Instant -> + TrustedTruststore.create( + untrustedOCSPList = untrustedOCSPList, + untrustedCertList = untrustedCertList, + trustAnchor = trustAnchor, + ocspResponseMaxAge = ocspResponseMaxAge, + timestamp = timestamp + ) + } ) - val pubKey = runBlocking { - useCase.withValidVauPublicKey { - it - } + val pubKey = useCase.withValidVauPublicKey { + it } println("Truststore established - received public key: ${pubKey.w.affineX} ${pubKey.w.affineY}") diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreTest.kt b/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreTest.kt index 2d08a8a0..a814d346 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/vau/TruststoreTest.kt @@ -18,7 +18,7 @@ package de.gematik.ti.erp.app.vau -import de.gematik.ti.erp.app.utils.CoroutineTestRule +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList import de.gematik.ti.erp.app.vau.repository.VauRepository @@ -38,8 +38,7 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest -import org.apache.commons.codec.binary.Base64 +import kotlinx.coroutines.test.runTest import org.bouncycastle.cert.ocsp.BasicOCSPResp import org.bouncycastle.cert.ocsp.OCSPResp import org.bouncycastle.jcajce.provider.asymmetric.ec.KeyFactorySpi @@ -52,6 +51,7 @@ import org.junit.Rule import org.junit.Test import java.security.interfaces.ECPublicKey import java.time.Duration +import java.util.Base64 @OptIn(ExperimentalCoroutinesApi::class) class TruststoreTest { @@ -85,11 +85,13 @@ class TruststoreTest { every { config.maxOCSPResponseAge } returns Duration.ofHours(12) every { config.trustAnchor } returns TestCertificates.RCA3.X509Certificate - every { timeSource.now() } returns ocspProducedAt + Duration.ofHours(2) + every { timeSource() } returns ocspProducedAt + Duration.ofHours(2) every { trustedTruststore.vauPublicKey } returns vauPublicKey - every { trustedTruststore.idpCertificates } returns listOf(TestCertificates.Idp1.X509Certificate, TestCertificates.Idp2.X509Certificate) + every { trustedTruststore.idpCertificates } returns + listOf(TestCertificates.Idp1.X509Certificate, TestCertificates.Idp2.X509Certificate) every { trustedTruststore.caCertificates } returns listOf(TestCertificates.CA10.X509Certificate) - every { trustedTruststore.ocspResponses } returns TestCertificates.OCSPList.OCSPList.responses.map { it.responseObject as BasicOCSPResp } + every { trustedTruststore.ocspResponses } returns + TestCertificates.OCSP.OCSPList.responses.map { it.responseObject as BasicOCSPResp } every { trustedTruststore.checkValidity(Duration.ofHours(12), ocspProducedAt) } coAnswers { } coEvery { repository.invalidate() } coAnswers { } @@ -103,7 +105,9 @@ class TruststoreTest { @Test fun `find valid cert chain for vau cert - returns one cert chain`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP3.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp( + Base64.getDecoder().decode(TestCertificates.OCSP3.Base64) + ).responseObject as BasicOCSPResp val certChain = listOf( TestCertificates.Vau.X509Certificate, TestCertificates.CA10.X509Certificate, @@ -119,8 +123,8 @@ class TruststoreTest { @Test fun `find valid cert chain for idp cert - returns one cert chain`() { val ocspResp = listOf( - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp, - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP2.Base64)).responseObject as BasicOCSPResp + OCSPResp(Base64.getDecoder().decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp, + OCSPResp(Base64.getDecoder().decode(TestCertificates.OCSP2.Base64)).responseObject as BasicOCSPResp ) val certChains = listOf( listOf( @@ -146,8 +150,8 @@ class TruststoreTest { @Test fun `find valid ocsp responses - returns two ocsp responses`() { val ocspResps = listOf( - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp, - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP2.Base64)).responseObject as BasicOCSPResp + OCSPResp(Base64.getDecoder().decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp, + OCSPResp(Base64.getDecoder().decode(TestCertificates.OCSP2.Base64)).responseObject as BasicOCSPResp ) val certChain = listOf(TestCertificates.CA10.X509Certificate, TestCertificates.RCA3.X509Certificate) @@ -165,8 +169,8 @@ class TruststoreTest { @Test fun `find valid ocsp responses with wrong ca chain - returns no responses`() { val ocspResps = listOf( - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp, - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP2.Base64)).responseObject as BasicOCSPResp + OCSPResp(Base64.getDecoder().decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp, + OCSPResp(Base64.getDecoder().decode(TestCertificates.OCSP2.Base64)).responseObject as BasicOCSPResp ) val certChain = listOf(TestCertificates.CA11.X509Certificate, TestCertificates.RCA3.X509Certificate) @@ -184,7 +188,7 @@ class TruststoreTest { @Test fun `create trusted truststore`() { val truststore = TrustedTruststore.create( - TestCertificates.OCSPList.OCSPList, + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -200,7 +204,7 @@ class TruststoreTest { assertTrue( try { TrustedTruststore.create( - TestCertificates.OCSPList.OCSPList, + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -218,16 +222,16 @@ class TruststoreTest { @Test fun `new instance of truststore contains no cached store - fetches and creates store from repository`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -247,19 +251,19 @@ class TruststoreTest { @Test fun `new instance of truststore contains no cached store - fetches and creates store with invalid ocsp from repository`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -267,7 +271,7 @@ class TruststoreTest { ) } answers { error("invalid ocsp") - } andThen { + } andThenAnswer { trustedTruststore } @@ -289,19 +293,19 @@ class TruststoreTest { @Test fun `truststore contains cached store - cached store is invalid - fetches and creates store with invalid ocsp from repository`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -345,19 +349,19 @@ class TruststoreTest { @Test(expected = Exception::class) fun `truststore creation finally fails`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -385,19 +389,19 @@ class TruststoreTest { @Test(expected = Exception::class) fun `truststore creation succeeds - block throws exception`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -427,19 +431,19 @@ class TruststoreTest { @Test fun `truststore creation succeeds - idp certificate found`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -464,19 +468,19 @@ class TruststoreTest { @Test(expected = IllegalArgumentException::class) fun `truststore creation succeeds - idp certificate not found`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -503,19 +507,19 @@ class TruststoreTest { @Test(expected = Exception::class) fun `truststore creation succeeds - idp certificate not found - invalidate`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { repository.withUntrusted(any()) } coAnswers { firstArg Boolean>().invoke( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } every { - trustedTruststoreProvider.create( - TestCertificates.OCSPList.OCSPList, + trustedTruststoreProvider( + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/repository/VauRepositoryTest.kt b/android/src/test/java/de/gematik/ti/erp/app/vau/repository/VauRepositoryTest.kt index 2bc7b90c..a08f658d 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/repository/VauRepositoryTest.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/vau/repository/VauRepositoryTest.kt @@ -18,15 +18,14 @@ package de.gematik.ti.erp.app.vau.repository -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.utils.CoroutineTestRule +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.vau.TestCertificates import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -51,20 +50,20 @@ class VauRepositoryTest { fun setup() { MockKAnnotations.init(this) - repo = VauRepository(localDataSource, remoteDataSource, coroutineRule.testDispatchProvider) + repo = VauRepository(localDataSource, remoteDataSource, coroutineRule.dispatchers) } @Test - fun `local database is empty - load from remote`() = coroutineRule.testDispatcher.runBlockingTest { + fun `local database is empty - load from remote`() = runTest { coEvery { localDataSource.loadUntrusted() } coAnswers { null } coEvery { localDataSource.saveLists(any(), any()) } coAnswers { } coEvery { localDataSource.deleteAll() } coAnswers { } - coEvery { remoteDataSource.loadCertificates() } coAnswers { Result.Success(TestCertificates.Vau.CertList) } - coEvery { remoteDataSource.loadOcspResponses() } coAnswers { Result.Success(TestCertificates.OCSPList.OCSPList) } + coEvery { remoteDataSource.loadCertificates() } coAnswers { Result.success(TestCertificates.Vau.CertList) } + coEvery { remoteDataSource.loadOcspResponses() } coAnswers { Result.success(TestCertificates.OCSP.OCSPList) } repo.withUntrusted { certs, ocsp -> assertEquals(TestCertificates.Vau.CertList, certs) - assertEquals(TestCertificates.OCSPList.OCSPList, ocsp) + assertEquals(TestCertificates.OCSP.OCSPList, ocsp) } coVerify(exactly = 1) { remoteDataSource.loadCertificates() } @@ -74,12 +73,12 @@ class VauRepositoryTest { } @Test - fun `local database is empty - load from remote fails`() = coroutineRule.testDispatcher.runBlockingTest { + fun `local database is empty - load from remote fails`() = runTest { coEvery { localDataSource.loadUntrusted() } coAnswers { null } coEvery { localDataSource.saveLists(any(), any()) } coAnswers { } coEvery { localDataSource.deleteAll() } coAnswers { } - coEvery { remoteDataSource.loadCertificates() } coAnswers { Result.Error(IOException()) } - coEvery { remoteDataSource.loadOcspResponses() } coAnswers { Result.Success(TestCertificates.OCSPList.OCSPList) } + coEvery { remoteDataSource.loadCertificates() } coAnswers { Result.failure(IOException()) } + coEvery { remoteDataSource.loadOcspResponses() } coAnswers { Result.success(TestCertificates.OCSP.OCSPList) } val r = try { repo.withUntrusted { certs, ocsp -> @@ -98,11 +97,11 @@ class VauRepositoryTest { } @Test - fun `local database is not empty`() = coroutineRule.testDispatcher.runBlockingTest { + fun `local database is not empty`() = runTest { coEvery { localDataSource.loadUntrusted() } coAnswers { Pair( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } coEvery { localDataSource.saveLists(any(), any()) } coAnswers { } @@ -110,7 +109,7 @@ class VauRepositoryTest { repo.withUntrusted { certs, ocsp -> assertEquals(TestCertificates.Vau.CertList, certs) - assertEquals(TestCertificates.OCSPList.OCSPList, ocsp) + assertEquals(TestCertificates.OCSP.OCSPList, ocsp) } coVerify(exactly = 0) { remoteDataSource.loadCertificates() } @@ -121,11 +120,11 @@ class VauRepositoryTest { @Test fun `local database is not empty - exception thrown in block of withUntrusted`() = - coroutineRule.testDispatcher.runBlockingTest { + runTest { coEvery { localDataSource.loadUntrusted() } coAnswers { Pair( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } @@ -135,7 +134,7 @@ class VauRepositoryTest { val r = try { repo.withUntrusted { certs, ocsp -> assertEquals(TestCertificates.Vau.CertList, certs) - assertEquals(TestCertificates.OCSPList.OCSPList, ocsp) + assertEquals(TestCertificates.OCSP.OCSPList, ocsp) error("fail") } diff --git a/build.gradle.kts b/build.gradle.kts index dd76832d..03803903 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,45 +1,35 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + // NOTE: Only pre-include plugins (apply false) required by the modules android, common // and desktop within this block to keep them excluded from the root module. // If the plugin can't be resolved add a custom resolution strategy to `settings.gradle.kts`. plugins { // reports versions of dependencies // e.g. `gradle dependencyUpdates` - id("com.github.ben-manes.versions") version "0.41.0" + id("com.github.ben-manes.versions") version "0.42.0" - id("org.owasp.dependencycheck") version "6.5.2.1" apply false + id("org.owasp.dependencycheck") version "7.1.0.1" apply false // generates licence report id("com.jaredsburrows.license") version "0.8.90" apply false - kotlin("multiplatform") version "1.6.10" apply false - kotlin("plugin.serialization") version "1.6.10" apply false - id("org.jetbrains.kotlin.android") version "1.6.10" apply false - id("com.android.application") version "7.0.4" apply false - id("com.android.library") version "7.0.4" apply false - id("dagger.hilt.android") version "2.40.5" apply false - id("org.jetbrains.compose") version "1.0.1" apply false + kotlin("multiplatform") version "1.7.0" apply false + kotlin("plugin.serialization") version "1.7.0" apply false + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" apply false + id("io.realm.kotlin") version "1.0.2" apply false + id("org.jetbrains.kotlin.android") version "1.7.0" apply false + id("com.android.application") version "7.3.1" apply false + id("com.android.library") version "7.3.1" apply false + id("org.jetbrains.compose") version "1.2.0-alpha01-dev753" apply false id("com.codingfeline.buildkonfig") version "0.11.0" apply false + id("io.gitlab.arturbosch.detekt") version "1.20.0" } -// BUG: Workaorund for missing metadata https://issuetracker.google.com/issues/206855609 -// TODO: Remove if we can upgrade to AGP >= 7.1.* -buildscript { - if (!System.getProperty("os.name").toLowerCase().contains("windows")) { - repositories { - maven("https://storage.googleapis.com/r8-releases/raw") - } - dependencies { - classpath("com.android.tools:r8:3.1.42") - } - } -} -// END - val ktlintMain by configurations.creating val ktlintRules by configurations.creating dependencies { - ktlintMain("com.pinterest:ktlint:0.42.1") { + ktlintMain("com.pinterest:ktlint:0.46.1") { attributes { attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.SHADOWED)) } @@ -58,6 +48,26 @@ val sourcesKt = listOf( "**/*.gradle.kts" ) +detekt { + source = + fileTree(rootDir) { + include(sourcesKt) + } + .filter { it.extension != "kts" } + .map { it.parentFile } + .let { + files(*it.toTypedArray()) + } + parallel = true + config = files("config/detekt/detekt.yml") + baseline = file("config/detekt/baseline.xml") + buildUponDefaultConfig = false + allRules = false + disableDefaultRuleSets = false + debug = false + ignoreFailures = false +} + fun ktlintCreating(format: Boolean, sources: List, disableLicenceRule: Boolean) = tasks.creating(JavaExec::class) { description = "Fix Kotlin code style deviations." @@ -68,6 +78,8 @@ fun ktlintCreating(format: Boolean, sources: List, disableLicenceRule: B addAll(sources) if (disableLicenceRule) add("--disabled_rules=custom:licence-header") } + // required for java > 16; see https://github.com/pinterest/ktlint/issues/1195 + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") } val ktlint by ktlintCreating(format = false, sources = sourcesKt, disableLicenceRule = false) @@ -78,3 +90,16 @@ tasks.register("clean", Delete::class) { delete(it.buildDir) } } + +fun isUnstable(version: String): Boolean = + version.contains("alpha", ignoreCase = true) + || version.contains("rc", ignoreCase = true) + || version.contains("beta", ignoreCase = true) + +tasks.withType { + outputFormatter = "txt,html" + rejectVersionIf { + // allows unstable to unstable updates but not stable to unstable + isUnstable(candidate.version) && !isUnstable(currentVersion) + } +} diff --git a/common/build.gradle.kts b/common/build.gradle.kts index e55678eb..f80a7716 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,6 +1,7 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.BOOLEAN import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.LONG import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING +import de.gematik.ti.erp.app import de.gematik.ti.erp.overriding import org.jetbrains.compose.compose import org.jetbrains.kotlin.util.capitalizeDecapitalize.capitalizeAsciiOnly @@ -9,6 +10,8 @@ import java.io.ByteArrayOutputStream plugins { id("com.android.library") kotlin("multiplatform") + kotlin("plugin.serialization") + id("io.realm.kotlin") id("org.jetbrains.compose") id("com.codingfeline.buildkonfig") id("de.gematik.ti.erp.dependencies") @@ -42,15 +45,15 @@ val PHARMACY_SERVICE_URI_TEST: String by overriding() val PHARMACY_API_KEY: String by overriding() val PHARMACY_API_KEY_TEST: String by overriding() -val PIWIK_TRACKER_URI: String by overriding() - val BASE_SERVICE_URI_PU: String by overriding() val BASE_SERVICE_URI_TU: String by overriding() val BASE_SERVICE_URI_RU: String by overriding() +val BASE_SERVICE_URI_RU_DEV: String by overriding() val BASE_SERVICE_URI_TR: String by overriding() val IDP_SERVICE_URI_PU: String by overriding() val IDP_SERVICE_URI_TU: String by overriding() val IDP_SERVICE_URI_RU: String by overriding() +val IDP_SERVICE_URI_RU_DEV: String by overriding() val IDP_SERVICE_URI_TR: String by overriding() val ERP_API_KEY_GOOGLE_PU: String by overriding() @@ -61,12 +64,17 @@ val ERP_API_KEY_HUAWEI_PU: String by overriding() val ERP_API_KEY_HUAWEI_TU: String by overriding() val ERP_API_KEY_HUAWEI_RU: String by overriding() val ERP_API_KEY_HUAWEI_TR: String by overriding() - -val PIWIK_TRACKER_ID_GOOGLE: String by overriding() -val PIWIK_TRACKER_ID_HUAWEI: String by overriding() +val ERP_API_KEY_DESKTOP_PU: String by overriding() +val ERP_API_KEY_DESKTOP_TU: String by overriding() +val ERP_API_KEY_DESKTOP_RU: String by overriding() val SAFETYNET_API_KEY: String by overriding() +val DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE: String by overriding() +val DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY: String by overriding() + +val DEBUG_VISUAL_TEST_TAGS: String? by project + kotlin { android() jvm("desktop") { @@ -77,20 +85,92 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - api(compose.runtime) - api(compose.foundation) - api(compose.material) - api(compose.materialIconsExtended) - api(compose.ui) + implementation(kotlin("reflect")) + app { + androidX { + implementation(paging("common-ktx")) { + // remove coroutine dependency; otherwise intellij will be confused with "duplicated class import" + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") + } + } + kotlinX { + implementation(coroutines("core")) + } + database { + implementation(realm) + } + crypto { + implementation(jose4j) + compileOnly(bouncyCastle("bcprov")) + compileOnly(bouncyCastle("bcpkix")) + } + serialization { + implementation(kotlinXJson) + } + logging { + implementation(napier) + } + network { + implementation(retrofit2("retrofit")) + implementation(okhttp3("okhttp")) + implementation(retrofit2KotlinXSerialization) + implementation(okhttp3("logging-interceptor")) + } + dependencyInjection { + implementation(kodein("di-framework-compose")) + } + } + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.materialIconsExtended) + implementation(compose.ui) } } val commonTest by getting { dependencies { + implementation(kotlin("reflect")) implementation(kotlin("test-common")) + implementation(kotlin("test")) + app { + database { + implementation(realm) + } + test { + implementation(junit4) + implementation(mockk("mockk")) + implementation(snakeyaml) + } + crypto { + implementation(jose4j) + implementation(bouncyCastle("bcprov", "jdk15on")) + implementation(bouncyCastle("bcpkix", "jdk15on")) + } + kotlinXTest { + implementation(coroutinesTest) + } + networkTest { + implementation(mockWebServer) + } + } } } val androidMain by getting { + dependsOn(commonMain) dependencies { + app { + android { + implementation(coreKtx) + } + crypto { + implementation(bouncyCastle("bcprov")) + implementation(bouncyCastle("bcpkix")) + } + dependencyInjection { + implementation(kodein("di-framework-android-x-viewmodel")) + implementation(kodein("di-framework-android-x-viewmodel-savedstate")) + } + } } } val androidTest by getting { @@ -98,15 +178,26 @@ kotlin { } } val desktopMain by getting { + dependsOn(commonMain) + dependencies { + implementation(compose.preview) + } + } + val desktopTest by getting { dependencies { - api(compose.preview) + app { + crypto { + implementation(bouncyCastle("bcprov", "jdk15on")) + implementation(bouncyCastle("bcpkix", "jdk15on")) + } + } } } - val desktopTest by getting } } android { + buildToolsVersion = "33.0.0" compileSdk = 31 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { @@ -117,6 +208,7 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + namespace = "de.gematik.ti.erp.lib" // namespace = "de.gematik.ti.erp.lib" } @@ -125,7 +217,7 @@ enum class Platforms { } enum class Environments { - PU, TU, RU, TR + PU, TU, RU, DEVRU, TR } enum class Types { @@ -139,9 +231,19 @@ buildkonfig { // default config is required defaultConfigs { buildConfigField(STRING, "GIT_HASH", getGitHash()) - buildConfigField(STRING, "PIWIK_TRACKER_URI", PIWIK_TRACKER_URI) buildConfigField(STRING, "SAFETYNET_API_KEY", SAFETYNET_API_KEY) + buildConfigField(STRING, "DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE", DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE) + buildConfigField(STRING, "DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY", DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY) buildConfigField(STRING, "BUILD_FLAVOR", project.property("buildkonfig.flavor") as String) + buildConfigField( + STRING, + "IDP_DEFAULT_SCOPE", + if (project.property("buildkonfig.flavor").toString().contains("rudev", true)) { + "e-rezept-dev openid" + } else { + "e-rezept openid" + } + ) } fun defaultConfigs( @@ -150,22 +252,50 @@ buildkonfig { baseServiceUri: String, idpServiceUri: String, erpApiKey: String, - piwikTrackerId: String?, pharmacyServiceUri: String, pharmacyServiceApiKey: String, trustAnchor: String, + ocspResponseMaxAge: String ) { defaultConfigs(flavor) { buildConfigField(BOOLEAN, "INTERNAL", isInternal.toString()) + if (isInternal) { + buildConfigField(STRING, "BASE_SERVICE_URI_PU", BASE_SERVICE_URI_PU) + buildConfigField(STRING, "BASE_SERVICE_URI_RU", BASE_SERVICE_URI_RU) + buildConfigField(STRING, "BASE_SERVICE_URI_TU", BASE_SERVICE_URI_TU) + buildConfigField(STRING, "BASE_SERVICE_URI_RU_DEV", BASE_SERVICE_URI_RU_DEV) + buildConfigField(STRING, "BASE_SERVICE_URI_TR", BASE_SERVICE_URI_TR) + + buildConfigField(STRING, "IDP_SERVICE_URI_PU", IDP_SERVICE_URI_PU) + buildConfigField(STRING, "IDP_SERVICE_URI_TU", IDP_SERVICE_URI_TU) + buildConfigField(STRING, "IDP_SERVICE_URI_RU", IDP_SERVICE_URI_RU) + buildConfigField(STRING, "IDP_SERVICE_URI_RU_DEV", IDP_SERVICE_URI_RU_DEV) + buildConfigField(STRING, "IDP_SERVICE_URI_TR", IDP_SERVICE_URI_TR) + + buildConfigField(STRING, "PHARMACY_SERVICE_URI_PU", PHARMACY_SERVICE_URI) + buildConfigField(STRING, "PHARMACY_SERVICE_URI_RU", PHARMACY_SERVICE_URI_TEST) + + buildConfigField(STRING, "ERP_API_KEY_GOOGLE_PU", ERP_API_KEY_GOOGLE_PU) + buildConfigField(STRING, "ERP_API_KEY_GOOGLE_RU", ERP_API_KEY_GOOGLE_RU) + buildConfigField(STRING, "ERP_API_KEY_GOOGLE_TU", ERP_API_KEY_GOOGLE_TU) + buildConfigField(STRING, "ERP_API_KEY_GOOGLE_TR", ERP_API_KEY_GOOGLE_TR) + + buildConfigField(STRING, "PHARMACY_API_KEY_PU", PHARMACY_API_KEY) + buildConfigField(STRING, "PHARMACY_API_KEY_RU", PHARMACY_API_KEY_TEST) + + buildConfigField(STRING, "APP_TRUST_ANCHOR_BASE64_PU", APP_TRUST_ANCHOR_BASE64) + buildConfigField(STRING, "APP_TRUST_ANCHOR_BASE64_TU", APP_TRUST_ANCHOR_BASE64_TEST) + } buildConfigField(STRING, "BASE_SERVICE_URI", baseServiceUri) buildConfigField(STRING, "IDP_SERVICE_URI", idpServiceUri) buildConfigField(STRING, "ERP_API_KEY", erpApiKey) - piwikTrackerId?.let { - buildConfigField(STRING, "PIWIK_TRACKER_ID", piwikTrackerId) - } buildConfigField(STRING, "PHARMACY_SERVICE_URI", pharmacyServiceUri) buildConfigField(STRING, "PHARMACY_API_KEY", pharmacyServiceApiKey) buildConfigField(STRING, "APP_TRUST_ANCHOR_BASE64", trustAnchor) + buildConfigField(LONG, "VAU_OCSP_RESPONSE_MAX_AGE", ocspResponseMaxAge) + + buildConfigField(BOOLEAN, "TEST_RUN_WITH_TRUSTSTORE_INTEGRATION", "false") + buildConfigField(BOOLEAN, "TEST_RUN_WITH_IDP_INTEGRATION", "false") } } @@ -190,51 +320,61 @@ buildkonfig { Environments.PU -> BASE_SERVICE_URI_PU Environments.TU -> BASE_SERVICE_URI_TU Environments.RU -> BASE_SERVICE_URI_RU + Environments.DEVRU -> BASE_SERVICE_URI_RU_DEV Environments.TR -> BASE_SERVICE_URI_TR }, idpServiceUri = when (environment) { Environments.PU -> IDP_SERVICE_URI_PU Environments.TU -> IDP_SERVICE_URI_TU Environments.RU -> IDP_SERVICE_URI_RU + Environments.DEVRU -> IDP_SERVICE_URI_RU_DEV Environments.TR -> IDP_SERVICE_URI_TR }, erpApiKey = when (platform) { - Platforms.Desktop, Platforms.Google, Platforms.Konnektathon -> when (environment) { + Platforms.Google, Platforms.Konnektathon -> when (environment) { Environments.PU -> ERP_API_KEY_GOOGLE_PU Environments.TU -> ERP_API_KEY_GOOGLE_TU + Environments.DEVRU, Environments.RU -> ERP_API_KEY_GOOGLE_RU Environments.TR -> ERP_API_KEY_GOOGLE_TR } + Platforms.Desktop -> when (environment) { + Environments.PU -> ERP_API_KEY_DESKTOP_PU + Environments.TU -> ERP_API_KEY_DESKTOP_TU + Environments.DEVRU, + Environments.RU -> ERP_API_KEY_DESKTOP_RU + Environments.TR -> ERP_API_KEY_GOOGLE_TR + } Platforms.Huawei -> when (environment) { Environments.PU -> ERP_API_KEY_HUAWEI_PU Environments.TU -> ERP_API_KEY_HUAWEI_TU + Environments.DEVRU, Environments.RU -> ERP_API_KEY_HUAWEI_RU Environments.TR -> ERP_API_KEY_HUAWEI_TR } }, - piwikTrackerId = when (platform) { - Platforms.Google, Platforms.Konnektathon -> PIWIK_TRACKER_ID_GOOGLE - Platforms.Huawei -> PIWIK_TRACKER_ID_HUAWEI - Platforms.Desktop -> null - }, pharmacyServiceUri = when (environment) { Environments.PU -> PHARMACY_SERVICE_URI Environments.TU, Environments.RU, + Environments.DEVRU, Environments.TR -> PHARMACY_SERVICE_URI_TEST }, pharmacyServiceApiKey = when (environment) { Environments.PU -> PHARMACY_API_KEY Environments.TU, Environments.RU, + Environments.DEVRU, Environments.TR -> PHARMACY_API_KEY_TEST }, trustAnchor = when (environment) { Environments.PU -> APP_TRUST_ANCHOR_BASE64 - Environments.TU -> APP_TRUST_ANCHOR_BASE64_TEST - Environments.RU -> APP_TRUST_ANCHOR_BASE64_TEST + Environments.TU, + Environments.RU, + Environments.DEVRU, Environments.TR -> APP_TRUST_ANCHOR_BASE64_TEST - } + }, + ocspResponseMaxAge = VAU_OCSP_RESPONSE_MAX_AGE ) } } @@ -248,13 +388,14 @@ buildkonfig { buildConfigField(STRING, "USER_AGENT", USER_AGENT) buildConfigField(STRING, "DATA_PROTECTION_LAST_UPDATED", DATA_PROTECTION_LAST_UPDATED) + // test tag config + buildConfigField(BOOLEAN, "DEBUG_VISUAL_TEST_TAGS", DEBUG_VISUAL_TEST_TAGS ?: "false") + // test configs - buildConfigField(BOOLEAN, "TEST_RUN_WITH_TRUSTSTORE_INTEGRATION", "false") buildConfigField(BOOLEAN, "DEBUG_TEST_IDS_ENABLED", DEBUG_TEST_IDS_ENABLED) // VAU feature toggles for development buildConfigField(BOOLEAN, "VAU_ENABLE_INTERCEPTOR", "true") - buildConfigField(LONG, "VAU_OCSP_RESPONSE_MAX_AGE", VAU_OCSP_RESPONSE_MAX_AGE) } } } diff --git a/common/src/androidMain/AndroidManifest.xml b/common/src/androidMain/AndroidManifest.xml index 82e48b97..5c3d3655 100644 --- a/common/src/androidMain/AndroidManifest.xml +++ b/common/src/androidMain/AndroidManifest.xml @@ -1,2 +1,2 @@ - + diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/SafetynetAttestationEntity.kt b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt similarity index 71% rename from android/src/main/java/de/gematik/ti/erp/app/db/entities/SafetynetAttestationEntity.kt rename to common/src/androidMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt index 2ea7b0b9..c0000107 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/SafetynetAttestationEntity.kt +++ b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt @@ -16,15 +16,14 @@ * */ -package de.gematik.ti.erp.app.db.entities +package de.gematik.ti.erp.app -import androidx.room.Entity -import androidx.room.PrimaryKey +import android.os.Build +import java.security.SecureRandom -@Entity(tableName = "safetynetattestations") -data class SafetynetAttestationEntity( - @PrimaryKey - val id: Int = 0, - val jws: String, - val ourNonce: ByteArray -) +actual fun secureRandomInstance(): SecureRandom = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SecureRandom.getInstanceStrong() + } else { + SecureRandom() + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/converter/ProfileColorConverter.kt b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt similarity index 65% rename from android/src/main/java/de/gematik/ti/erp/app/db/converter/ProfileColorConverter.kt rename to common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt index 414c3201..844b263d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/converter/ProfileColorConverter.kt +++ b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt @@ -16,15 +16,14 @@ * */ -package de.gematik.ti.erp.app.db.converter +package de.gematik.ti.erp.app.idp.usecase -import androidx.room.TypeConverter -import de.gematik.ti.erp.app.db.entities.ProfileColorNames +import java.security.KeyStore +import java.security.Signature -class ProfileColorsConverter { - @TypeConverter - fun toProfileColors(color: String) = enumValueOf(color) - - @TypeConverter - fun fromProfileColors(color: ProfileColorNames) = color.name +actual class IdpCryptoProvider { + actual fun keyStoreInstance(): KeyStore = + KeyStore.getInstance("AndroidKeyStore") + actual fun signatureInstance(algorithm: String): Signature = + Signature.getInstance(algorithm, "AndroidKeyStoreBCWorkaround") } diff --git a/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt new file mode 100644 index 00000000..1001b802 --- /dev/null +++ b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +actual class IdpDeviceInfoProvider { + actual val deviceName: String = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}" + actual val manufacturer: String = android.os.Build.MANUFACTURER + actual val productName: String = android.os.Build.PRODUCT + actual val model: String = android.os.Build.MODEL + actual val operatingSystem: String = "Android" + actual val operatingSystemVersion: String = android.os.Build.VERSION.SDK_INT.toString() +} diff --git a/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt new file mode 100644 index 00000000..ef4d8461 --- /dev/null +++ b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +import android.content.SharedPreferences +import androidx.core.content.edit + +private const val EXT_AUTH_CODE_CHALLENGE: String = "EXT_AUTH_CODE_CHALLENGE" +private const val EXT_AUTH_CODE_VERIFIER: String = "EXT_AUTH_CODE_VERIFIER" +private const val EXT_AUTH_STATE: String = "EXT_AUTH_STATE" +private const val EXT_AUTH_NONCE: String = "EXT_AUTH_NONCE" +private const val EXT_AUTH_SCOPE: String = "EXT_AUTH_SCOPE" +private const val EXT_AUTH_ID: String = "EXT_AUTH_ID" +private const val EXT_AUTH_NAME: String = "EXT_AUTH_NAME" +private const val EXT_AUTH_PROFILE: String = "EXT_AUTH_PROFILE" + +actual class IdpPreferenceProvider { + lateinit var sharedPreferences: SharedPreferences + + actual var externalAuthenticationPreferences: ExternalAuthenticationPreferences + get() = ExternalAuthenticationPreferences( + extAuthCodeChallenge = sharedPreferences.getString(EXT_AUTH_CODE_CHALLENGE, null), + extAuthCodeVerifier = sharedPreferences.getString(EXT_AUTH_CODE_VERIFIER, null), + extAuthState = sharedPreferences.getString(EXT_AUTH_STATE, null), + extAuthNonce = sharedPreferences.getString(EXT_AUTH_NONCE, null), + extAuthId = sharedPreferences.getString(EXT_AUTH_ID, null), + extAuthScope = sharedPreferences.getString(EXT_AUTH_SCOPE, null), + extAuthName = sharedPreferences.getString(EXT_AUTH_NAME, null), + extAuthProfile = sharedPreferences.getString(EXT_AUTH_PROFILE, null) + ) + set(value) { + sharedPreferences.edit(commit = true) { + putString(EXT_AUTH_STATE, value.extAuthState) + putString(EXT_AUTH_NONCE, value.extAuthNonce) + putString(EXT_AUTH_CODE_VERIFIER, value.extAuthCodeVerifier) + putString(EXT_AUTH_CODE_CHALLENGE, value.extAuthCodeChallenge) + putString(EXT_AUTH_SCOPE, value.extAuthScope) + putString(EXT_AUTH_ID, value.extAuthId) + putString(EXT_AUTH_NAME, value.extAuthName) + putString(EXT_AUTH_PROFILE, value.extAuthProfile) + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/core/BaseViewModel.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/BCProvider.kt similarity index 80% rename from android/src/main/java/de/gematik/ti/erp/app/core/BaseViewModel.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/BCProvider.kt index 4ce1bae7..447ac334 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/core/BaseViewModel.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/BCProvider.kt @@ -16,9 +16,8 @@ * */ -package de.gematik.ti.erp.app.core +package de.gematik.ti.erp.app -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.ViewModel +import org.bouncycastle.jce.provider.BouncyCastleProvider -open class BaseViewModel : ViewModel(), LifecycleObserver +val BCProvider = BouncyCastleProvider() diff --git a/android/src/main/java/de/gematik/ti/erp/app/CryptoUtils.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/CryptoUtils.kt similarity index 80% rename from android/src/main/java/de/gematik/ti/erp/app/CryptoUtils.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/CryptoUtils.kt index f221d33c..24d8718e 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/CryptoUtils.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/CryptoUtils.kt @@ -18,8 +18,6 @@ package de.gematik.ti.erp.app -import android.os.Build -import java.security.SecureRandom import javax.crypto.KeyGenerator import javax.crypto.SecretKey @@ -27,9 +25,3 @@ fun generateRandomAES256Key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256, secureRandomInstance()) }.generateKey() - -fun secureRandomInstance(): SecureRandom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - SecureRandom.getInstanceStrong() -} else { - SecureRandom() -} diff --git a/android/src/main/java/de/gematik/ti/erp/app/DispatchProvider.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/DispatchProvider.kt similarity index 71% rename from android/src/main/java/de/gematik/ti/erp/app/DispatchProvider.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/DispatchProvider.kt index 59c5ebfd..0bea3aad 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/DispatchProvider.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/DispatchProvider.kt @@ -20,15 +20,10 @@ package de.gematik.ti.erp.app import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import javax.inject.Inject interface DispatchProvider { - - fun main(): CoroutineDispatcher = Dispatchers.Main - fun default(): CoroutineDispatcher = Dispatchers.Default - fun io(): CoroutineDispatcher = Dispatchers.IO - fun unconfined(): CoroutineDispatcher = Dispatchers.Unconfined + val Main: CoroutineDispatcher get() = Dispatchers.Main + val Default: CoroutineDispatcher get() = Dispatchers.Default + val IO: CoroutineDispatcher get() = Dispatchers.IO + val Unconfined: CoroutineDispatcher get() = Dispatchers.Unconfined } - -class DefaultDispatchProvider @Inject constructor() : - DispatchProvider diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/AuditEventWithMedicationText.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt similarity index 77% rename from android/src/main/java/de/gematik/ti/erp/app/db/entities/AuditEventWithMedicationText.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt index 1745bf2f..22985f5e 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/AuditEventWithMedicationText.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt @@ -16,12 +16,8 @@ * */ -package de.gematik.ti.erp.app.db.entities +package de.gematik.ti.erp.app -import java.time.OffsetDateTime +import java.security.SecureRandom -data class AuditEventWithMedicationText( - val medicationText: String?, - val text: String, - val timestamp: OffsetDateTime, -) +expect fun secureRandomInstance(): SecureRandom diff --git a/android/src/main/java/de/gematik/ti/erp/app/api/ErpService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt similarity index 61% rename from android/src/main/java/de/gematik/ti/erp/app/api/ErpService.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt index 426163ad..81c61588 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/api/ErpService.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ErpService.kt @@ -18,12 +18,12 @@ package de.gematik.ti.erp.app.api -import okhttp3.ResponseBody -import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.Communication +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.serialization.json.JsonElement import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -33,47 +33,54 @@ interface ErpService { @GET("Task/{id}") suspend fun taskWithKBVBundle( - @Tag profileName: String, + @Tag profileId: ProfileIdentifier, @Path("id") id: String - ): Response + ): Response @POST("Task/{id}/\$abort") - suspend fun deleteTask(@Tag profileName: String, @Path("id") id: String): Response + suspend fun deleteTask(@Tag profileId: ProfileIdentifier, @Path("id") id: String): Response /** * @param lastUpdated expects format like that ge2021-01-31T10:00 where "ge" represents Greater or Equal */ @GET("Task") suspend fun allTasks( - @Tag profileName: String, + @Tag profileId: ProfileIdentifier, @Query("modified") lastUpdated: String? - ): Response + ): Response /** * @param lastKnownDate expects format like that ge2021-01-31T10:00 where ge stays for Greater or Equal. Null value will remove this query parameter * @param sort refers to the date attribute ASC */ @GET("AuditEvent") - suspend fun allAuditEvents( - @Tag profileName: String, + suspend fun getAuditEvents( + @Tag profileId: ProfileIdentifier, @Query("date") lastKnownDate: String?, @Query("_sort") sort: String = "+date", @Query("_count") count: Int? = null, @Query("__offset") offset: Int? = null - ): Response + ): Response @POST("Communication") - suspend fun communication( - @Tag profileName: String, - @Body communication: Communication - ): Response + suspend fun postCommunication( + @Tag profileId: ProfileIdentifier, + @Body communication: JsonElement, + @Header("X-AccessCode") accessCode: String? = null + ): Response @GET("Communication") - suspend fun communication(@Tag profileName: String): Response + suspend fun getCommunications( + @Tag profileId: ProfileIdentifier, + @Query("sent") lastKnownDate: String?, + @Query("_sort") sort: String = "+sent", + @Query("_count") count: Int? = null, + @Query("__offset") offset: Int? = null + ): Response - @GET("MedicationDispense/{id}") - suspend fun medicationDispense( + @GET("MedicationDispense") + suspend fun bundleOfMedicationDispenses( @Tag profileName: String, - @Path("id") id: String - ): Response + @Query("identifier") id: String + ): Response } diff --git a/android/src/main/java/de/gematik/ti/erp/app/api/NetworkUtil.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/NetworkUtil.kt similarity index 64% rename from android/src/main/java/de/gematik/ti/erp/app/api/NetworkUtil.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/NetworkUtil.kt index 811bd4c5..21f27ba6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/api/NetworkUtil.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/NetworkUtil.kt @@ -18,6 +18,8 @@ package de.gematik.ti.erp.app.api +import io.github.aakira.napier.Napier +import kotlinx.coroutines.CancellationException import retrofit2.Response import java.io.IOException @@ -34,15 +36,18 @@ suspend fun safeApiCall( try { val response = call() if (response.isSuccessful) { - response.body()?.let { Result.Success(it) } ?: Result.Success(null) + requireNotNull(response.body()).let { Result.success(it) } } else { - Result.Error( + Result.failure( ApiCallException("Error executing safe api call ${response.code()} ${response.message()}", response) ) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + Napier.e("Api Call Error", e) // An exception was thrown when calling the API so we're converting this to an [IOException] - Result.Error(IOException(errorMessage, e)) + Result.failure(IOException(errorMessage, e)) } /** @@ -55,6 +60,25 @@ suspend fun safeApiCallRaw( try { call() } catch (e: Exception) { + Napier.e("Api Call Error", e) // An exception was thrown when calling the API so we're converting this to an IOException - Result.Error(IOException(errorMessage, e)) + Result.failure(IOException(errorMessage, e)) + } + +suspend fun safeApiCallNullable( + errorMessage: String, + call: suspend () -> Response +): Result = + try { + val response = call() + if (response.isSuccessful) { + response.body()?.let { Result.success(it) } ?: Result.success(null) + } else { + Result.failure( + ApiCallException("Error executing safe api call ${response.code()} ${response.message()}", response) + ) + } + } catch (e: Exception) { + // An exception was thrown when calling the API so we're converting this to an [IOException] + Result.failure(IOException(errorMessage, e)) } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/VauService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacyRedeemService.kt similarity index 66% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/VauService.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacyRedeemService.kt index d1f091ba..d107e4c5 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/VauService.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacyRedeemService.kt @@ -16,17 +16,21 @@ * */ -package de.gematik.ti.erp.app.vau.api +package de.gematik.ti.erp.app.api -import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList -import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList +import okhttp3.RequestBody import retrofit2.Response -import retrofit2.http.GET +import retrofit2.http.Body +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url -interface VauService { - @GET("CertList") - suspend fun getCertList(): Response +interface PharmacyRedeemService { - @GET("OCSPList") - suspend fun getOcspResponses(): Response + @POST + @Headers("Content-Type: application/pkcs7-mime") + suspend fun redeem( + @Url url: String, + @Body message: RequestBody + ): Response } diff --git a/android/src/main/java/de/gematik/ti/erp/app/api/PharmacySearchService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt similarity index 81% rename from android/src/main/java/de/gematik/ti/erp/app/api/PharmacySearchService.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt index 3d64c014..54127563 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/api/PharmacySearchService.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/PharmacySearchService.kt @@ -18,7 +18,7 @@ package de.gematik.ti.erp.app.api -import org.hl7.fhir.r4.model.Bundle +import kotlinx.serialization.json.JsonElement import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Query @@ -30,13 +30,18 @@ interface PharmacySearchService { suspend fun search( @Query("name") names: List, @QueryMap attributes: Map - ): Response + ): Response // paging realised through session referenced by the previous bundle id @GET("api") suspend fun searchByBundle( @Query("_getpages") bundleId: String, @Query("_getpagesoffset") offset: Int, - @Query("_count") count: Int, - ): Response + @Query("_count") count: Int + ): Response + + @GET("api/Location") + suspend fun searchByTelematikId( + @Query("identifier") telematikId: String + ): Response } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ResourcePaging.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ResourcePaging.kt new file mode 100644 index 00000000..6de4e7d1 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/ResourcePaging.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.api + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.github.aakira.napier.Napier +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +abstract class ResourcePaging( + private val dispatchers: DispatchProvider, + private val maxPageSize: Int +) { + private val lock = Mutex() + + protected suspend fun downloadPaged(profileId: ProfileIdentifier): Result = lock.withLock { + withContext(dispatchers.IO) { + downloadAll(profileId) + } + } + + private suspend fun downloadAll(profileId: ProfileIdentifier): Result { + while (true) { + downloadResource( + profileId = profileId, + timestamp = toTimestampString(syncedUpTo(profileId)), + count = maxPageSize + ).onFailure { + return@downloadAll Result.failure(it) + }.onSuccess { + Napier.d { "Received $it entries" } + if (it != maxPageSize) { + return@downloadAll Result.success(Unit) + } + } + } + } + + private fun toTimestampString(timestamp: Instant?) = + timestamp?.let { + val tm = it.atOffset(ZoneOffset.UTC) + .truncatedTo(ChronoUnit.SECONDS) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + + "gt$tm" + } + + /** + * Downloads the specific resource and returns the size of the received list. + */ + protected abstract suspend fun downloadResource( + profileId: ProfileIdentifier, + timestamp: String?, + count: Int? + ): Result + + protected abstract suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/apicheck/usecase/CheckVersionUseCase.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/apicheck/usecase/CheckVersionUseCase.kt new file mode 100644 index 00000000..de29657f --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/apicheck/usecase/CheckVersionUseCase.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.apicheck.usecase + +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.HttpURLConnection +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.DispatchProvider +import io.github.aakira.napier.Napier +import kotlinx.coroutines.withContext +import java.io.IOException + +class CheckVersionUseCase( + private val okHttp: OkHttpClient, + private val dispatchers: DispatchProvider +) { + suspend fun isUpdateRequired(): Boolean = withContext(dispatchers.IO) { + if (BuildKonfig.INTERNAL) { + return@withContext false + } + + try { + val response = okHttp.newCall( + Request.Builder() + .header("X-Api-Key", BuildKonfig.ERP_API_KEY) + .url(BuildKonfig.BASE_SERVICE_URI + "CertList") + .get() + .build() + ).execute() + + response.code == HttpURLConnection.HTTP_UNAUTHORIZED + } catch (e: IOException) { + Napier.e(e) { "Couldn't check if api key is expired" } + false + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/CardUtilities.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/CardUtilities.kt similarity index 94% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/CardUtilities.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/CardUtilities.kt index 64b0adb4..1cb57b52 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/CardUtilities.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/CardUtilities.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc +package de.gematik.ti.erp.app.card.model import de.gematik.ti.erp.app.BCProvider import org.bouncycastle.asn1.ASN1InputStream @@ -51,7 +51,10 @@ object CardUtilities { System.arraycopy(byteArray, 1, x, 0, (byteArray.size - 1) / 2) System.arraycopy( - byteArray, 1 + (byteArray.size - 1) / 2, y, 0, + byteArray, + 1 + (byteArray.size - 1) / 2, + y, + 0, (byteArray.size - 1) / 2 ) curve.createPoint(BigInteger(1, x), BigInteger(1, y)) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/CardKey.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/CardKey.kt similarity index 96% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/CardKey.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/CardKey.kt index 06afa310..d11e3543 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/CardKey.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/CardKey.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.card +package de.gematik.ti.erp.app.card.model.card private const val MIN_KEY_ID = 2 private const val MAX_KEY_ID = 28 diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/EncryptedPinFormat2.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/EncryptedPinFormat2.kt similarity index 98% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/EncryptedPinFormat2.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/EncryptedPinFormat2.kt index 6c3873f8..20bf974f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/EncryptedPinFormat2.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/EncryptedPinFormat2.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.card.model.card /** * The format 2 PIN block has been specified for use with IC cards. The format 2 PIN block shall only be used in diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/HealthCardVersion2.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/HealthCardVersion2.kt similarity index 98% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/HealthCardVersion2.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/HealthCardVersion2.kt index 913883ef..2eb04785 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/HealthCardVersion2.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/HealthCardVersion2.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.card +package de.gematik.ti.erp.app.card.model.card import org.bouncycastle.asn1.ASN1InputStream import org.bouncycastle.asn1.DEROctetString diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/ICardChannel.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/ICardChannel.kt similarity index 90% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/ICardChannel.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/ICardChannel.kt index 8125091e..82d214be 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/ICardChannel.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/ICardChannel.kt @@ -16,10 +16,10 @@ * */ -package de.gematik.ti.erp.app.nfc.model.card +package de.gematik.ti.erp.app.card.model.card -import de.gematik.ti.erp.app.nfc.model.command.CommandApdu -import de.gematik.ti.erp.app.nfc.model.command.ResponseApdu +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu /** * Interface to a (logical) channel of a smart card. @@ -31,7 +31,7 @@ interface ICardChannel { /** * Returns the Card this channel is associated with. */ - val card: NfcHealthCard + val card: IHealthCard /** * Max transceive length diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/ICardKeyReference.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/ICardKeyReference.kt similarity index 95% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/ICardKeyReference.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/ICardKeyReference.kt index 29d0f804..1bd0f7be 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/ICardKeyReference.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/ICardKeyReference.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.card +package de.gematik.ti.erp.app.card.model.card /** * interface that identifier: diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/IHealthCard.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/IHealthCard.kt new file mode 100644 index 00000000..591f7b83 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/IHealthCard.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.card.model.card + +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu + +interface IHealthCard { + fun transmit(apduCommand: CommandApdu): ResponseApdu +} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/PaceKey.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PaceKey.kt similarity index 96% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/PaceKey.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PaceKey.kt index 56095fef..5a017b63 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/PaceKey.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PaceKey.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.card +package de.gematik.ti.erp.app.card.model.card /** * Pace Key for TrustedChannel with Session key for encoding and Session key for message authentication diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/Password.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PasswordReference.kt similarity index 93% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/Password.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PasswordReference.kt index fa5d50ae..7ba0e804 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/Password.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PasswordReference.kt @@ -18,6 +18,8 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.card +import de.gematik.ti.erp.app.card.model.card.ICardKeyReference + /** * A password can be a regular password or multireference password * @@ -30,7 +32,7 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.card private const val MIN_PWD_ID = 0 private const val MAX_PWD_ID = 31 -class Password(val pwdId: Int) : ICardKeyReference { +class PasswordReference(val pwdId: Int) : ICardKeyReference { init { require(!(pwdId < MIN_PWD_ID || pwdId > MAX_PWD_ID)) { // gemSpec_COS#N015.000 diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/PsoAlgorithm.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PsoAlgorithm.kt similarity index 95% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/PsoAlgorithm.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PsoAlgorithm.kt index a4417400..3881eeda 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/PsoAlgorithm.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/PsoAlgorithm.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.card +package de.gematik.ti.erp.app.card.model.card /** * Represent a specific PSO Algorithm diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/SecureMessaging.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/SecureMessaging.kt similarity index 92% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/SecureMessaging.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/SecureMessaging.kt index 4562637a..a2bdea28 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/SecureMessaging.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/card/SecureMessaging.kt @@ -16,14 +16,13 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.card.model.card -import android.annotation.SuppressLint import de.gematik.ti.erp.app.BCProvider -import de.gematik.ti.erp.app.cardwall.model.nfc.command.CommandApdu -import de.gematik.ti.erp.app.cardwall.model.nfc.command.EXPECTED_LENGTH_WILDCARD_EXTENDED -import de.gematik.ti.erp.app.cardwall.model.nfc.command.EXPECTED_LENGTH_WILDCARD_SHORT -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseApdu +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.EXPECTED_LENGTH_WILDCARD_EXTENDED +import de.gematik.ti.erp.app.card.model.command.EXPECTED_LENGTH_WILDCARD_SHORT +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import de.gematik.ti.erp.app.cardwall.model.nfc.tagobjects.DataObject import de.gematik.ti.erp.app.cardwall.model.nfc.tagobjects.LengthObject import de.gematik.ti.erp.app.cardwall.model.nfc.tagobjects.MacObject @@ -31,8 +30,6 @@ import de.gematik.ti.erp.app.cardwall.model.nfc.tagobjects.StatusObject import de.gematik.ti.erp.app.utils.Bytes.padData import de.gematik.ti.erp.app.utils.Bytes.unPadData import org.bouncycastle.asn1.DERTaggedObject -import org.bouncycastle.util.encoders.Hex -import timber.log.Timber import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream @@ -83,8 +80,6 @@ class SecureMessaging(private val paceKey: PaceKey) { fun encrypt(commandApdu: CommandApdu): CommandApdu { val apduToEncrypt = commandApdu.bytes // copy - Timber.d("Plain APDU: %s", Hex.toHexString(apduToEncrypt)) - incrementSSC() require(apduToEncrypt.size >= HEADER_SIZE) { "APDU must be at least 4 bytes long" } @@ -114,8 +109,6 @@ class SecureMessaging(private val paceKey: PaceKey) { LengthObject(it).taggedObject.encodeTo(commandDataOutput) } ?: -1 - Timber.d("build encrypted command") - val commandMacObject = MacObject(header, commandDataOutput, paceKey.mac, secureMessagingSSC) return createEncryptedCommand( le = le, @@ -137,9 +130,8 @@ class SecureMessaging(private val paceKey: PaceKey) { le: Int, data: ByteArrayOutputStream, do8E: DERTaggedObject, - header: ByteArray, + header: ByteArray ): CommandApdu { - val tempData = data // write do8E to output do8E.encodeTo(data) @@ -174,8 +166,6 @@ class SecureMessaging(private val paceKey: PaceKey) { val statusBytes = ByteArray(2) val macBytes = ByteArray(MAC_SIZE) - Timber.d("Encrypted Response APDU: %s", Hex.toHexString(apduResponseBytes)) - val responseDataOutput = ByteArrayOutputStream() require(apduResponseBytes.size >= MIN_RESPONSE_SIZE) { MALFORMED_SECURE_MESSAGING_APDU } @@ -264,8 +254,6 @@ class SecureMessaging(private val paceKey: PaceKey) { getCipher(DECRYPT_MODE).doFinal(it) } outputStream.write(unPadData(dataDecrypted)) - - Timber.d("data decrypted: %s", Hex.toHexString(dataDecrypted)) } else { outputStream.write(dataObject.data) } @@ -286,7 +274,6 @@ class SecureMessaging(private val paceKey: PaceKey) { init(mode, key, aps) } - @SuppressLint("GetInstance") private fun createCipherIV(): ByteArray = // ECB instead of CBC on purpose. COS doesn't support CBC for this. Cipher.getInstance("AES/ECB/NoPadding", BCProvider).let { diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/cardobjects/FileSystem.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/cardobjects/FileSystem.kt similarity index 96% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/cardobjects/FileSystem.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/cardobjects/FileSystem.kt index 4b354db2..ba6396f4 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/cardobjects/FileSystem.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/cardobjects/FileSystem.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.cardobjects +package de.gematik.ti.erp.app.card.model.cardobjects /** * eGK 2.1 file system objects @@ -45,7 +45,6 @@ object Mf { object MrPinHome { const val PWID = 0x02 } - object Df { object Esign { object Ef { @@ -54,7 +53,6 @@ object Mf { const val SFID = 0x04 } } - object PrK { object ChAutE256 { const val KID = 0x04 diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/Apdu.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/Apdu.kt similarity index 99% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/Apdu.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/Apdu.kt index b5ae68f2..37e5a28e 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/Apdu.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/Apdu.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command import java.io.ByteArrayOutputStream diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/VerifyCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ChangeReferenceDataCommand.kt similarity index 60% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/VerifyCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ChangeReferenceDataCommand.kt index 43d20667..30cfe6dd 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/VerifyCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ChangeReferenceDataCommand.kt @@ -16,32 +16,30 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.cardwall.model.nfc.card.EncryptedPinFormat2 -import de.gematik.ti.erp.app.cardwall.model.nfc.card.Password - -/** - * Command representing Verify Secret Command gemSpec_COS#14.6.6 - */ +import de.gematik.ti.erp.app.card.model.card.EncryptedPinFormat2 +import de.gematik.ti.erp.app.cardwall.model.nfc.card.PasswordReference private const val CLA = 0x00 -private const val INS = 0x20 +private const val INS = 0x24 private const val MODE_VERIFICATION_DATA = 0x00 /** - * Use case Change Password Secret (Pin) gemSpec_COS#14.6.6.1 + * Use case change reference data gemSpec_COS#14.6.1.1 */ -fun HealthCardCommand.Companion.verifyPin( - password: Password, +fun HealthCardCommand.Companion.changeReferenceData( + passwordReference: PasswordReference, dfSpecific: Boolean, - pin: EncryptedPinFormat2 + oldSecret: EncryptedPinFormat2, + newSecret: EncryptedPinFormat2 ) = HealthCardCommand( - expectedStatus = verifyStatus, + expectedStatus = changeReferenceDataStatus, cla = CLA, ins = INS, p1 = MODE_VERIFICATION_DATA, - p2 = password.calculateKeyReference(dfSpecific), - data = pin.bytes + p2 = passwordReference.calculateKeyReference(dfSpecific), + data = + oldSecret.bytes + newSecret.bytes ) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/GeneralAuthenticateCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/GeneralAuthenticateCommand.kt similarity index 85% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/GeneralAuthenticateCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/GeneralAuthenticateCommand.kt index d51d30d1..5523f697 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/GeneralAuthenticateCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/GeneralAuthenticateCommand.kt @@ -16,11 +16,11 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import org.bouncycastle.asn1.ASN1EncodableVector -import org.bouncycastle.asn1.DERApplicationSpecific +import org.bouncycastle.asn1.BERTags import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence import org.bouncycastle.asn1.DERTaggedObject private const val CLA_COMMAND_CHAINING = 0x10 @@ -44,7 +44,7 @@ fun HealthCardCommand.Companion.generalAuthenticate(commandChaining: Boolean) = ins = INS, p1 = NO_MEANING, p2 = NO_MEANING, - data = DERApplicationSpecific(28, ASN1EncodableVector()).encoded, + data = DERTaggedObject(false, BERTags.APPLICATION, 28, DERSequence()).encoded, ne = NE_MAX_SHORT_LENGTH ) @@ -65,9 +65,6 @@ fun HealthCardCommand.Companion.generalAuthenticate( ins = INS, p1 = NO_MEANING, p2 = NO_MEANING, - data = DERApplicationSpecific( - 28, - DERTaggedObject(false, tagNo, DEROctetString(data)) - ).encoded, + data = DERTaggedObject(true, BERTags.APPLICATION, 28, DERTaggedObject(false, tagNo, DEROctetString(data))).encoded, ne = NE_MAX_SHORT_LENGTH ) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/GetPinStatusCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/GetPinStatusCommand.kt similarity index 85% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/GetPinStatusCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/GetPinStatusCommand.kt index 4cf7e8de..b969d5af 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/GetPinStatusCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/GetPinStatusCommand.kt @@ -16,9 +16,9 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.nfc.model.card.Password +import de.gematik.ti.erp.app.cardwall.model.nfc.card.PasswordReference /** * Command representing Get Pin Status Command gemSpec_COS#14.6.4 @@ -35,7 +35,7 @@ private const val NO_MEANING = 0x00 * @param dfSpecific whether or not the password object specifies a Global or DF-specific. * true = DF-Specific, false = global */ -fun HealthCardCommand.Companion.getPinStatus(password: Password, dfSpecific: Boolean) = +fun HealthCardCommand.Companion.getPinStatus(password: PasswordReference, dfSpecific: Boolean) = HealthCardCommand( expectedStatus = pinStatus, cla = CLA, diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/HealthCardCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/HealthCardCommand.kt similarity index 93% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/HealthCardCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/HealthCardCommand.kt index ebc9cb77..9013a526 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/HealthCardCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/HealthCardCommand.kt @@ -16,10 +16,9 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.nfc.model.card.ICardChannel -import io.github.aakira.napier.Napier +import de.gematik.ti.erp.app.card.model.card.ICardChannel private const val HEX_FF = 0xff @@ -87,7 +86,6 @@ class HealthCardResponse(val status: ResponseStatus, val apdu: ResponseApdu) fun HealthCardCommand.executeSuccessfulOn(channel: ICardChannel): HealthCardResponse = this.executeOn(channel).also { - Napier.d("response status: ${it.status}") it.requireSuccess() } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ManageSecurityEnvironmentCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ManageSecurityEnvironmentCommand.kt similarity index 93% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ManageSecurityEnvironmentCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ManageSecurityEnvironmentCommand.kt index b180fade..6743f808 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ManageSecurityEnvironmentCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ManageSecurityEnvironmentCommand.kt @@ -16,10 +16,10 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.nfc.model.card.CardKey -import de.gematik.ti.erp.app.nfc.model.card.PsoAlgorithm +import de.gematik.ti.erp.app.card.model.card.CardKey +import de.gematik.ti.erp.app.card.model.card.PsoAlgorithm import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.DERTaggedObject @@ -40,7 +40,7 @@ private const val MODE_AFFECTED_LIST_ELEMENT_IS_SIGNATURE_CREATION = 0xB6 fun HealthCardCommand.Companion.manageSecEnvWithoutCurves( cardKey: CardKey, dfSpecific: Boolean, - oid: ByteArray?, + oid: ByteArray? ) = HealthCardCommand( expectedStatus = manageSecurityEnvironmentStatus, diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/PsoComputeDigitalSignatureCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/PsoComputeDigitalSignatureCommand.kt similarity index 95% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/PsoComputeDigitalSignatureCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/PsoComputeDigitalSignatureCommand.kt index 92d7b3c4..821acd23 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/PsoComputeDigitalSignatureCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/PsoComputeDigitalSignatureCommand.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command private const val CLA = 0x00 private const val INS = 0x2A diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ReadCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ReadCommand.kt similarity index 95% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ReadCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ReadCommand.kt index 21996cfa..bd5e32a4 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ReadCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ReadCommand.kt @@ -16,9 +16,9 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.nfc.model.identifier.ShortFileIdentifier +import de.gematik.ti.erp.app.card.model.identifier.ShortFileIdentifier private const val CLA = 0x00 private const val INS = 0xB0 diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ResponseStatus.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ResponseStatus.kt similarity index 79% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ResponseStatus.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ResponseStatus.kt index 64777166..d37440f1 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ResponseStatus.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/ResponseStatus.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.card.model.command val generalAuthenticateStatus = mapOf( 0x0000 to ResponseStatus.UNKNOWN_STATUS, @@ -75,10 +75,10 @@ val selectStatus = mapOf( 0x6283 to ResponseStatus.FILE_DEACTIVATED, 0x6285 to ResponseStatus.FILE_TERMINATED, 0x6A82 to ResponseStatus.FILE_NOT_FOUND, - 0x6D00 to ResponseStatus.INSTRUCTION_NOT_SUPPORTED, + 0x6D00 to ResponseStatus.INSTRUCTION_NOT_SUPPORTED ) -val verifyStatus = mapOf( +val verifySecretStatus = mapOf( 0x9000 to ResponseStatus.SUCCESS, 0x63C0 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_00, 0x63C1 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, @@ -88,7 +88,39 @@ val verifyStatus = mapOf( 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, 0x6983 to ResponseStatus.PASSWORD_BLOCKED, 0x6985 to ResponseStatus.PASSWORD_NOT_USABLE, - 0x6988 to ResponseStatus.PASSWORD_NOT_FOUND, + 0x6988 to ResponseStatus.PASSWORD_NOT_FOUND +) + +val unlockEgkStatus = mapOf( + 0x9000 to ResponseStatus.SUCCESS, + 0x6983 to ResponseStatus.PUK_BLOCKED, + 0x63C0 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_00, + 0x63C1 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, + 0x63C2 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_02, + 0x63C3 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_03, + 0x63C4 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_04, + 0x63C5 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_05, + 0x63C6 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_06, + 0x63C7 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_07, + 0x63C8 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_08, + 0x63C9 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_09, + 0x6581 to ResponseStatus.MEMORY_FAILURE, + 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, + 0x6985 to ResponseStatus.WRONG_PASSWORD_LENGTH, + 0x6A88 to ResponseStatus.PASSWORD_NOT_FOUND +) + +val changeReferenceDataStatus = mapOf( + 0x9000 to ResponseStatus.SUCCESS, + 0x63C0 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_00, + 0x63C1 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, + 0x63C2 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_02, + 0x63C3 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_03, // oldSecret wrong + 0x6581 to ResponseStatus.MEMORY_FAILURE, + 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, + 0x6983 to ResponseStatus.PASSWORD_BLOCKED, + 0x6985 to ResponseStatus.WRONG_PASSWORD_LENGTH, + 0x6A88 to ResponseStatus.PASSWORD_NOT_FOUND ) /** @@ -210,5 +242,6 @@ enum class ResponseStatus { DUPLICATED_OBJECTS, DF_NAME_EXISTS, OFFSET_TOO_BIG, - INSTRUCTION_NOT_SUPPORTED; + INSTRUCTION_NOT_SUPPORTED, + PUK_BLOCKED } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/SelectCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/SelectCommand.kt similarity index 96% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/SelectCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/SelectCommand.kt index 5995adbb..1fb0604c 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/SelectCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/SelectCommand.kt @@ -16,10 +16,10 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.nfc.model.identifier.ApplicationIdentifier -import de.gematik.ti.erp.app.nfc.model.identifier.FileIdentifier +import de.gematik.ti.erp.app.card.model.identifier.ApplicationIdentifier +import de.gematik.ti.erp.app.card.model.identifier.FileIdentifier private const val CLA = 0x00 private const val INS = 0xA4 diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/UnlockEgkCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/UnlockEgkCommand.kt new file mode 100644 index 00000000..fd3dccb2 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/UnlockEgkCommand.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.card.model.command + +import de.gematik.ti.erp.app.card.model.card.EncryptedPinFormat2 +import de.gematik.ti.erp.app.cardwall.model.nfc.card.PasswordReference + +private const val CLA = 0x00 +private const val UNLOCK_EGK_INS = 0x2C +private const val MODE_VERIFICATION_DATA_NEW_SECRET = 0x00 +private const val MODE_VERIFICATION_DATA = 0x01 + +enum class UnlockMethod { + ChangeReferenceData, + ResetRetryCounterWithNewSecret, + ResetRetryCounter, + None +} + +/** + * Use case unlock eGK with/without Secret (Pin) gemSpec_COS#14.6.5.1 und gemSpec_COS#14.6.5.2 + */ +fun HealthCardCommand.Companion.unlockEgk( + unlockMethod: UnlockMethod, + passwordReference: PasswordReference, + dfSpecific: Boolean, + puk: EncryptedPinFormat2, + newSecret: EncryptedPinFormat2? +) = + HealthCardCommand( + expectedStatus = unlockEgkStatus, + cla = CLA, + ins = UNLOCK_EGK_INS, + p1 = if (unlockMethod == UnlockMethod.ResetRetryCounterWithNewSecret) { + MODE_VERIFICATION_DATA_NEW_SECRET + } else { + MODE_VERIFICATION_DATA + }, + p2 = passwordReference.calculateKeyReference(dfSpecific), + data = if (unlockMethod == UnlockMethod.ResetRetryCounterWithNewSecret) { + puk.bytes + (newSecret?.bytes ?: byteArrayOf()) + } else { + puk.bytes + } + ) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/VerifyCommand.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/VerifyCommand.kt similarity index 72% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/VerifyCommand.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/VerifyCommand.kt index c19c616c..b40d1298 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/VerifyCommand.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/VerifyCommand.kt @@ -16,32 +16,28 @@ * */ -package de.gematik.ti.erp.app.nfc.model.command +package de.gematik.ti.erp.app.card.model.command -import de.gematik.ti.erp.app.nfc.model.card.EncryptedPinFormat2 -import de.gematik.ti.erp.app.nfc.model.card.Password - -/** - * Command representing Verify Secret Command gemSpec_COS#14.6.6 - */ +import de.gematik.ti.erp.app.card.model.card.EncryptedPinFormat2 +import de.gematik.ti.erp.app.cardwall.model.nfc.card.PasswordReference private const val CLA = 0x00 -private const val INS = 0x20 +private const val VERIFY_SECRET_INS = 0x20 private const val MODE_VERIFICATION_DATA = 0x00 /** - * Use case Change Password Secret (Pin) gemSpec_COS#14.6.6.1 + * Command representing Verify Secret Command gemSpec_COS#14.6.6 */ fun HealthCardCommand.Companion.verifyPin( - password: Password, + passwordReference: PasswordReference, dfSpecific: Boolean, pin: EncryptedPinFormat2 ) = HealthCardCommand( - expectedStatus = verifyStatus, + expectedStatus = verifySecretStatus, cla = CLA, - ins = INS, + ins = VERIFY_SECRET_INS, p1 = MODE_VERIFICATION_DATA, - p2 = password.calculateKeyReference(dfSpecific), + p2 = passwordReference.calculateKeyReference(dfSpecific), data = pin.bytes ) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/CertificateExchange.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/CertificateExchange.kt similarity index 61% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/CertificateExchange.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/CertificateExchange.kt index f0b6854b..0e27f437 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/CertificateExchange.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/CertificateExchange.kt @@ -16,23 +16,22 @@ * */ -package de.gematik.ti.erp.app.nfc.model.exchange +package de.gematik.ti.erp.app.card.model.exchange -import de.gematik.ti.erp.app.nfc.model.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.nfc.model.cardobjects.Df -import de.gematik.ti.erp.app.nfc.model.cardobjects.Mf -import de.gematik.ti.erp.app.nfc.model.command.EXPECTED_LENGTH_WILDCARD_EXTENDED -import de.gematik.ti.erp.app.nfc.model.command.HealthCardCommand -import de.gematik.ti.erp.app.nfc.model.command.ResponseStatus -import de.gematik.ti.erp.app.nfc.model.command.executeSuccessfulOn -import de.gematik.ti.erp.app.nfc.model.command.read -import de.gematik.ti.erp.app.nfc.model.command.select -import de.gematik.ti.erp.app.nfc.model.identifier.ApplicationIdentifier -import de.gematik.ti.erp.app.nfc.model.identifier.FileIdentifier -import io.github.aakira.napier.Napier +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.cardobjects.Df +import de.gematik.ti.erp.app.card.model.cardobjects.Mf +import de.gematik.ti.erp.app.card.model.command.EXPECTED_LENGTH_WILDCARD_EXTENDED +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.ResponseStatus +import de.gematik.ti.erp.app.card.model.command.executeSuccessfulOn +import de.gematik.ti.erp.app.card.model.command.read +import de.gematik.ti.erp.app.card.model.command.select +import de.gematik.ti.erp.app.card.model.identifier.ApplicationIdentifier +import de.gematik.ti.erp.app.card.model.identifier.FileIdentifier import java.io.ByteArrayOutputStream -fun NfcCardSecureChannel.retrieveCertificate(): ByteArray { +fun ICardChannel.retrieveCertificate(): ByteArray { HealthCardCommand.select(ApplicationIdentifier(Df.Esign.AID)).executeSuccessfulOn(this) HealthCardCommand.select( FileIdentifier(Mf.Df.Esign.Ef.CchAutE256.FID), @@ -47,10 +46,7 @@ fun NfcCardSecureChannel.retrieveCertificate(): ByteArray { val response = HealthCardCommand.read(offset) .executeOn(this) - Napier.d("Response was ${response.status}") - val data = response.apdu.data - Napier.d("Read ${data.size} bytes. Offset $offset") if (data.isNotEmpty()) { buffer.write(data) @@ -58,8 +54,7 @@ fun NfcCardSecureChannel.retrieveCertificate(): ByteArray { } when (response.status) { - ResponseStatus.SUCCESS -> { - } + ResponseStatus.SUCCESS -> { } ResponseStatus.END_OF_FILE_WARNING, ResponseStatus.OFFSET_TOO_BIG -> break else -> error("Couldn't read certificate: ${response.status}") diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/KeyDerivationFunction.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/KeyDerivationFunction.kt similarity index 97% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/KeyDerivationFunction.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/KeyDerivationFunction.kt index dd9ecbca..124a5efb 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/KeyDerivationFunction.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/KeyDerivationFunction.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.exchange +package de.gematik.ti.erp.app.card.model.exchange import org.bouncycastle.crypto.digests.SHA1Digest diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/PaceInfo.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PaceInfo.kt similarity index 95% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/PaceInfo.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PaceInfo.kt index b512a052..cb755c9a 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/PaceInfo.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PaceInfo.kt @@ -16,9 +16,9 @@ * */ -package de.gematik.ti.erp.app.nfc.model.exchange +package de.gematik.ti.erp.app.card.model.exchange -import de.gematik.ti.erp.app.nfc.model.CardUtilities +import de.gematik.ti.erp.app.card.model.CardUtilities import org.bouncycastle.asn1.ASN1InputStream import org.bouncycastle.asn1.ASN1Integer import org.bouncycastle.asn1.ASN1ObjectIdentifier diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PinExchange.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PinExchange.kt new file mode 100644 index 00000000..0b288231 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PinExchange.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.card.model.exchange + +import de.gematik.ti.erp.app.card.model.card.EncryptedPinFormat2 +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.cardwall.model.nfc.card.PasswordReference +import de.gematik.ti.erp.app.card.model.cardobjects.Mf +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.ResponseStatus +import de.gematik.ti.erp.app.card.model.command.UnlockMethod +import de.gematik.ti.erp.app.card.model.command.changeReferenceData +import de.gematik.ti.erp.app.card.model.command.unlockEgk +import de.gematik.ti.erp.app.card.model.command.executeSuccessfulOn +import de.gematik.ti.erp.app.card.model.command.select +import de.gematik.ti.erp.app.card.model.command.verifyPin + +fun ICardChannel.verifyPin(pin: String): ResponseStatus { + HealthCardCommand.select(selectParentElseRoot = false, readFirst = false) + .executeSuccessfulOn(this) + + val passwordReference = PasswordReference(Mf.MrPinHome.PWID) + + val response = + HealthCardCommand.verifyPin( + passwordReference = passwordReference, + dfSpecific = false, + pin = EncryptedPinFormat2(pin) + ).executeOn(this) + + require( + when (response.status) { + ResponseStatus.SUCCESS, + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02, + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03, + ResponseStatus.PASSWORD_BLOCKED -> + true + else -> + false + } + ) { "Verify pin command failed with status: ${response.status}" } + + return response.status +} + +fun ICardChannel.unlockEgk( + unlockMethod: UnlockMethod, + puk: String, + oldSecret: String, + newSecret: String +): ResponseStatus { + HealthCardCommand.select(selectParentElseRoot = false, readFirst = false) + .executeSuccessfulOn(this) + + val passwordReference = PasswordReference(Mf.MrPinHome.PWID) + + val response = if (unlockMethod == UnlockMethod.ChangeReferenceData) { + HealthCardCommand.changeReferenceData( + passwordReference = passwordReference, + dfSpecific = false, + oldSecret = EncryptedPinFormat2(oldSecret), + newSecret = EncryptedPinFormat2(newSecret) + ).executeSuccessfulOn(this) + } else { + HealthCardCommand.unlockEgk( + unlockMethod = unlockMethod, + passwordReference = passwordReference, + dfSpecific = false, + puk = EncryptedPinFormat2(puk), + newSecret = if (unlockMethod == UnlockMethod.ResetRetryCounterWithNewSecret) { + EncryptedPinFormat2(newSecret) + } else { null } + ).executeSuccessfulOn(this) + } + + require( + when (response.status) { + ResponseStatus.SUCCESS -> + true + else -> + false + } + ) { "Change secret command failed with status: ${response.status}" } + + return response.status +} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/SignChallengeExchange.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/SignChallengeExchange.kt similarity index 54% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/SignChallengeExchange.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/SignChallengeExchange.kt index 80b9b572..b728e5bb 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/SignChallengeExchange.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/SignChallengeExchange.kt @@ -16,26 +16,27 @@ * */ -package de.gematik.ti.erp.app.nfc.model.exchange +package de.gematik.ti.erp.app.card.model.exchange -import de.gematik.ti.erp.app.nfc.model.card.CardKey -import de.gematik.ti.erp.app.nfc.model.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.nfc.model.card.PsoAlgorithm -import de.gematik.ti.erp.app.nfc.model.cardobjects.Df -import de.gematik.ti.erp.app.nfc.model.cardobjects.Mf -import de.gematik.ti.erp.app.nfc.model.command.HealthCardCommand -import de.gematik.ti.erp.app.nfc.model.command.executeSuccessfulOn -import de.gematik.ti.erp.app.nfc.model.command.manageSecEnvForSigning -import de.gematik.ti.erp.app.nfc.model.command.psoComputeDigitalSignature -import de.gematik.ti.erp.app.nfc.model.command.select -import de.gematik.ti.erp.app.nfc.model.identifier.ApplicationIdentifier +import de.gematik.ti.erp.app.card.model.card.CardKey +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.card.PsoAlgorithm +import de.gematik.ti.erp.app.card.model.cardobjects.Df +import de.gematik.ti.erp.app.card.model.cardobjects.Mf +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.executeSuccessfulOn +import de.gematik.ti.erp.app.card.model.command.manageSecEnvForSigning +import de.gematik.ti.erp.app.card.model.command.psoComputeDigitalSignature +import de.gematik.ti.erp.app.card.model.command.select +import de.gematik.ti.erp.app.card.model.identifier.ApplicationIdentifier -fun NfcCardSecureChannel.signChallenge(challenge: ByteArray): ByteArray { +fun ICardChannel.signChallenge(challenge: ByteArray): ByteArray { HealthCardCommand.select(ApplicationIdentifier(Df.Esign.AID)).executeSuccessfulOn(this) HealthCardCommand.manageSecEnvForSigning( PsoAlgorithm.SIGN_VERIFY_ECDSA, - CardKey(Mf.Df.Esign.PrK.ChAutE256.KID), true + CardKey(Mf.Df.Esign.PrK.ChAutE256.KID), + true ).executeSuccessfulOn(this) return HealthCardCommand.psoComputeDigitalSignature(challenge) diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/TrustedChannelPaceKeyExchange.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/TrustedChannelPaceKeyExchange.kt similarity index 75% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/TrustedChannelPaceKeyExchange.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/TrustedChannelPaceKeyExchange.kt index 6354f98c..6a4d4859 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/exchange/TrustedChannelPaceKeyExchange.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/TrustedChannelPaceKeyExchange.kt @@ -18,35 +18,36 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.exchange -import de.gematik.ti.erp.app.cardwall.model.nfc.CardUtilities.byteArrayToECPoint -import de.gematik.ti.erp.app.cardwall.model.nfc.CardUtilities.extractKeyObjectEncoded -import de.gematik.ti.erp.app.cardwall.model.nfc.card.CardKey -import de.gematik.ti.erp.app.cardwall.model.nfc.card.HealthCardVersion2 -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcCardChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.card.PaceKey -import de.gematik.ti.erp.app.cardwall.model.nfc.card.isEGK21 -import de.gematik.ti.erp.app.cardwall.model.nfc.cardobjects.Ef -import de.gematik.ti.erp.app.cardwall.model.nfc.command.HealthCardCommand -import de.gematik.ti.erp.app.cardwall.model.nfc.command.executeSuccessfulOn -import de.gematik.ti.erp.app.cardwall.model.nfc.command.generalAuthenticate -import de.gematik.ti.erp.app.cardwall.model.nfc.command.manageSecEnvWithoutCurves -import de.gematik.ti.erp.app.cardwall.model.nfc.command.read -import de.gematik.ti.erp.app.cardwall.model.nfc.command.select -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.KeyDerivationFunction.getAES128Key -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.FileIdentifier -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ShortFileIdentifier +import de.gematik.ti.erp.app.card.model.CardUtilities.byteArrayToECPoint +import de.gematik.ti.erp.app.card.model.CardUtilities.extractKeyObjectEncoded +import de.gematik.ti.erp.app.card.model.card.CardKey +import de.gematik.ti.erp.app.card.model.card.HealthCardVersion2 +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.card.PaceKey +import de.gematik.ti.erp.app.card.model.card.isEGK21 +import de.gematik.ti.erp.app.card.model.cardobjects.Ef +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.executeSuccessfulOn +import de.gematik.ti.erp.app.card.model.command.generalAuthenticate +import de.gematik.ti.erp.app.card.model.command.manageSecEnvWithoutCurves +import de.gematik.ti.erp.app.card.model.command.read +import de.gematik.ti.erp.app.card.model.command.select +import de.gematik.ti.erp.app.card.model.exchange.KeyDerivationFunction +import de.gematik.ti.erp.app.card.model.exchange.KeyDerivationFunction.getAES128Key +import de.gematik.ti.erp.app.card.model.exchange.PaceInfo +import de.gematik.ti.erp.app.card.model.identifier.FileIdentifier +import de.gematik.ti.erp.app.card.model.identifier.ShortFileIdentifier import de.gematik.ti.erp.app.secureRandomInstance import de.gematik.ti.erp.app.utils.Bytes import org.bouncycastle.asn1.ASN1EncodableVector import org.bouncycastle.asn1.ASN1ObjectIdentifier -import org.bouncycastle.asn1.DERApplicationSpecific +import org.bouncycastle.asn1.BERTags import org.bouncycastle.asn1.DEROctetString +import org.bouncycastle.asn1.DERSequence import org.bouncycastle.asn1.DERTaggedObject import org.bouncycastle.crypto.engines.AESEngine import org.bouncycastle.crypto.macs.CMac import org.bouncycastle.crypto.params.KeyParameter -import org.bouncycastle.util.encoders.Hex -import timber.log.Timber import java.math.BigInteger private const val SECRET_KEY_REFERENCE = 2 // Reference of secret key for PACE (CAN) @@ -62,7 +63,7 @@ private const val TAG_49 = 0x49 * picc = card * pcd = smartphone */ -suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): PaceKey { +suspend fun ICardChannel.establishTrustedChannel(cardAccessNumber: String): PaceKey { val randomGenerator = secureRandomInstance() suspend fun step0ReadSupportedPaceParameters(step1: suspend (paceInfo: PaceInfo) -> PaceKey): PaceKey { @@ -94,13 +95,10 @@ suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): Pa paceInfo: PaceInfo, nonceSInt: BigInteger, pcdSkX1: BigInteger, - pcdPk1: ByteArray, - ) -> PaceKey, + pcdPk1: ByteArray + ) -> PaceKey ): PaceKey { val nonceZBytes = HealthCardCommand.generalAuthenticate(true).executeSuccessfulOn(this).apdu.data - - Timber.d("nonceZBytes: %s", Hex.toHexString(nonceZBytes)) - val nonceZBytesEncoded = extractKeyObjectEncoded(nonceZBytes) val canBytes = cardAccessNumber.toByteArray() val aes128Key = getAES128Key(canBytes, KeyDerivationFunction.Mode.PASSWORD) @@ -130,14 +128,12 @@ suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): Pa step3: suspend ( paceInfo: PaceInfo, pcdSkX2: BigInteger, - pcdPkS2: ByteArray, - ) -> PaceKey, + pcdPkS2: ByteArray + ) -> PaceKey ): PaceKey { val piccPk1Bytes = HealthCardCommand.generalAuthenticate(true, pcdPk1, 1).executeSuccessfulOn(this).apdu.data - Timber.d("piccPk1Bytes: %s", Hex.toHexString(piccPk1Bytes)) - val piccPk1BytesEncoded = extractKeyObjectEncoded(piccPk1Bytes) val y1 = byteArrayToECPoint(piccPk1BytesEncoded, paceInfo.ecCurve) val x2 = ByteArray(paceInfo.ecCurve.fieldSize / BYTE_LENGTH) @@ -158,27 +154,20 @@ suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): Pa pcdPkS2: ByteArray, step4: suspend ( piccMacDerived: ByteArray, - pcdMac: ByteArray, - ) -> Boolean, + pcdMac: ByteArray + ) -> Boolean ): PaceKey { val piccPk2Bytes = HealthCardCommand.generalAuthenticate(true, pcdPkS2, 3).executeSuccessfulOn(this).apdu.data - Timber.d("piccPk2: %s", Hex.toHexString(piccPk2Bytes)) - val piccPk2 = extractKeyObjectEncoded(piccPk2Bytes) val piccPk2ECPoint = byteArrayToECPoint(piccPk2, paceInfo.ecCurve) val sharedSecretK = piccPk2ECPoint.multiply(pcdSkX2) - val sharedSekBigInt = sharedSecretK.normalize().xCoord.toBigInteger() - - Timber.d("BIGINT:$sharedSekBigInt") val sharedSecretKBytes: ByteArray = Bytes.bigIntToByteArray(sharedSecretK.normalize().xCoord.toBigInteger()) - Timber.d("sharedSecretKBytes: %s", Hex.toHexString(sharedSecretKBytes)) - val paceKey = PaceKey( getAES128Key(sharedSecretKBytes, KeyDerivationFunction.Mode.ENC), getAES128Key(sharedSecretKBytes, KeyDerivationFunction.Mode.MAC) @@ -194,13 +183,12 @@ suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): Pa fun step4VerifyPcdAndPiccMac( piccMacDerived: ByteArray, - pcdMac: ByteArray, + pcdMac: ByteArray ): Boolean { val piccMacBytes = HealthCardCommand.generalAuthenticate(false, pcdMac, 5) .executeSuccessfulOn(this).apdu.data - Timber.d("macPiccBytes: %s", Hex.toHexString(piccMacBytes)) val piccMac = extractKeyObjectEncoded(piccMacBytes) return piccMac.contentEquals(piccMacDerived) @@ -209,15 +197,10 @@ suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): Pa /** * Negotiate the PaceKey and return the object */ - Timber.d("start step 0 ----") return step0ReadSupportedPaceParameters { paceInfo -> - Timber.d("start step 1 ----") step1EphemeralPublicKeyFirst(paceInfo) { _, nonceSInt, pcdSkX1, pcdPk1 -> - Timber.d("start step 2 ----") step2EphemeralPublicKeySecond(paceInfo, nonceSInt, pcdSkX1, pcdPk1) { _, pcdSkX2, pcdPkS2 -> - Timber.d("start step 3 ----") step3MutualAuthentication(paceInfo, pcdSkX2, pcdPkS2) { piccMacDerived, pcdMac -> - Timber.d("start step 4 ----") step4VerifyPcdAndPiccMac(piccMacDerived, pcdMac) } } @@ -235,7 +218,7 @@ private fun createAsn1AuthToken(ecPoint: ByteArray, protocolID: String): ByteArr DEROctetString(ecPoint) ) ) - return DERApplicationSpecific(TAG_49, asn1EncodableVector).encoded + return DERTaggedObject(false, BERTags.APPLICATION, TAG_49, DERSequence(asn1EncodableVector)).encoded } private fun deriveMac(mac: ByteArray, publicKey: ByteArray, protocolID: String): ByteArray = diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/ApplicationIdentifier.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/ApplicationIdentifier.kt similarity index 96% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/ApplicationIdentifier.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/ApplicationIdentifier.kt index b03f81ec..bd7bf0e7 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/ApplicationIdentifier.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/ApplicationIdentifier.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.identifier +package de.gematik.ti.erp.app.card.model.identifier import org.bouncycastle.util.encoders.Hex diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/FileIdentifier.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/FileIdentifier.kt similarity index 97% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/FileIdentifier.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/FileIdentifier.kt index 89ab5c6d..c1c5419c 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/FileIdentifier.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/FileIdentifier.kt @@ -16,7 +16,7 @@ * */ -package de.gematik.ti.erp.app.nfc.model.identifier +package de.gematik.ti.erp.app.card.model.identifier import org.bouncycastle.util.encoders.Hex import java.nio.ByteBuffer diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/ShortFileIdentifier.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/ShortFileIdentifier.kt similarity index 89% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/ShortFileIdentifier.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/ShortFileIdentifier.kt index 61aac50d..38182bc2 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/identifier/ShortFileIdentifier.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/identifier/ShortFileIdentifier.kt @@ -16,9 +16,9 @@ * */ -package de.gematik.ti.erp.app.nfc.model.identifier +package de.gematik.ti.erp.app.card.model.identifier -import okio.ByteString.Companion.decodeHex +import org.bouncycastle.util.encoders.Hex /** * It is possible that the attribute type shortFileIdentifier is used by the file object types. @@ -35,11 +35,10 @@ class ShortFileIdentifier(val sfId: Int) { sanityCheck() } - constructor(hexSfId: String) : this(hexSfId.decodeHex().toByteArray()[0].toInt()) + constructor(hexSfId: String) : this(Hex.decode(hexSfId)[0].toInt()) private fun sanityCheck() { require(!(sfId < MIN_VALUE || sfId > MAX_VALUE)) { - // gemSpec_COS#N007.000 String.format( "Short File Identifier out of valid range [%d,%d]", diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/DataObject.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/DataObject.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/DataObject.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/DataObject.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/LengthObject.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/LengthObject.kt similarity index 95% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/LengthObject.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/LengthObject.kt index 91b95cd0..6cceb1df 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/LengthObject.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/LengthObject.kt @@ -18,7 +18,7 @@ package de.gematik.ti.erp.app.cardwall.model.nfc.tagobjects -import de.gematik.ti.erp.app.cardwall.model.nfc.command.EXPECTED_LENGTH_WILDCARD_SHORT +import de.gematik.ti.erp.app.card.model.command.EXPECTED_LENGTH_WILDCARD_SHORT import org.bouncycastle.asn1.DEROctetString import org.bouncycastle.asn1.DERTaggedObject diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/MacObject.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/MacObject.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/MacObject.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/MacObject.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/StatusObject.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/StatusObject.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/tagobjects/StatusObject.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/tagobjects/StatusObject.kt diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt new file mode 100644 index 00000000..29a617fb --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.AuditEventEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.AvatarFigureV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpAuthenticationDataEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpConfigurationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PasswordEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PharmacyCacheEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SafetynetAttestationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.TruststoreEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.FavoritePharmacyEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OftenUsedPharmacyEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import io.realm.kotlin.ext.query + +const val ACTUAL_SCHEMA_VERSION = 11L + +val appSchemas = setOf( + AppRealmSchema( + version = ACTUAL_SCHEMA_VERSION, + classes = setOf( + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + TruststoreEntityV1::class, + SafetynetAttestationEntityV1::class, + IdpConfigurationEntityV1::class, + ProfileEntityV1::class, + CommunicationEntityV1::class, + MedicationEntityV1::class, + MedicationDispenseEntityV1::class, + MedicationRequestEntityV1::class, + OrganizationEntityV1::class, + PatientEntityV1::class, + PractitionerEntityV1::class, + ScannedTaskEntityV1::class, + SyncedTaskEntityV1::class, + AuditEventEntityV1::class, + IdpAuthenticationDataEntityV1::class, + AddressEntityV1::class, + InsuranceInformationEntityV1::class, + ShippingContactEntityV1::class, + IngredientEntityV1::class, + QuantityEntityV1::class, + RatioEntityV1::class, + PharmacyCacheEntityV1::class, + OftenUsedPharmacyEntityV1::class, + MultiplePrescriptionInfoEntityV1::class, + FavoritePharmacyEntityV1::class + ), + migrateOrInitialize = { migrationStartedFrom -> + queryFirst() ?: run { + copyToRealm( + SettingsEntityV1() + ) + } + if (migrationStartedFrom < 3L) { + query().find().forEach { profile -> + profile.syncedTasks.forEach { syncedTask -> + syncedTask.parent = profile + + syncedTask.communications.forEach { + it.parent = syncedTask + it.orderId = "" + } + } + profile.scannedTasks.forEach { scannedTask -> + scannedTask.parent = profile + } + } + } + if (migrationStartedFrom < 10L) { + query().find().forEach { + it._avatarFigure = AvatarFigureV1.PersonalizedImage.toString() + } + } + } + ) +) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/QueryUtils.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/QueryUtils.kt new file mode 100644 index 00000000..965fd9a4 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/QueryUtils.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import io.realm.kotlin.MutableRealm +import io.realm.kotlin.Realm +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.TypedRealm +import kotlinx.coroutines.delay + +inline fun TypedRealm.queryFirst( + query: String = "TRUEPREDICATE", + vararg args: Any? +): T? = query(T::class, query, *args).first().find() + +inline fun MutableRealm.queryFirst( + query: String = "TRUEPREDICATE", + vararg args: Any? +): T? = query(T::class, query, *args).first().find() + +/** + * If a query for [T] returns null a new object resulting from [factory] will be copied to the realm with + * a previous call to [block]. + * + * See also [writeToRealm]. + */ +suspend inline fun Realm.writeOrCopyToRealm( + crossinline factory: () -> T, + crossinline block: MutableRealm.(T) -> R +): R? = + write { + queryFirst()?.let { + block(it) + } ?: run { + block(copyToRealm(factory())) + } + } + +/** + * Queries [T] and calls [block] with the concrete instance of [T] as its receiver. + * [block] will only be called if any object of type [T] is present. + */ +suspend inline fun Realm.writeToRealm( + crossinline block: MutableRealm.(T) -> R +): R? = + write { + queryFirst()?.let { + block(it) + } + } + +/** + * Queries [T] and calls [block] with the concrete instance of [T] as its receiver. + * [block] will only be called if any object of type [T] is present. + */ +suspend inline fun Realm.writeToRealm( + query: String = "TRUEPREDICATE", + vararg args: Any?, + crossinline block: MutableRealm.(T) -> R +): R? = + write { + queryFirst(query, *args)?.let { + block(it) + } + } + +suspend fun Realm.tryWrite(block: MutableRealm.() -> R): R { + delay(100) + return write { + try { + block() + } catch (t: Throwable) { + cancelWrite() + throw t + } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/RealmInstantConverter.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/RealmInstantConverter.kt new file mode 100644 index 00000000..750aa914 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/RealmInstantConverter.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import io.realm.kotlin.types.RealmInstant +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +fun RealmInstant.toLocalDateTime(offset: ZoneOffset = ZoneOffset.UTC): LocalDateTime = + LocalDateTime.ofEpochSecond(epochSeconds, nanosecondsOfSecond, offset) + +fun LocalDateTime.toRealmInstant(offset: ZoneOffset = ZoneOffset.UTC) = + RealmInstant.from(toEpochSecond(offset), toLocalTime().nano) + +fun RealmInstant.toInstant(): Instant = + when { + this == RealmInstant.MIN -> Instant.MIN + this == RealmInstant.MAX -> Instant.MAX + else -> Instant.ofEpochSecond(epochSeconds, nanosecondsOfSecond.toLong()) + } + +fun Instant.toRealmInstant() = + when { + this == Instant.MIN -> RealmInstant.MIN + this == Instant.MAX -> RealmInstant.MAX + else -> RealmInstant.from(epochSecond, nano) + } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Schema.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Schema.kt new file mode 100644 index 00000000..4f3d7a26 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Schema.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import io.realm.kotlin.MutableRealm +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.ext.query +import io.realm.kotlin.types.RealmObject +import kotlin.reflect.KClass + +class AppRealmSchema( + val version: Long, + val classes: Set>, + val migrateOrInitialize: (MutableRealm.(migrationStartedFrom: Long) -> Unit)? = null +) { + override fun equals(other: Any?): Boolean { + return (other as? AppRealmSchema)?.version == version + } + + override fun hashCode(): Int { + return version.hashCode() + } +} + +class LatestManualMigration : RealmObject { + var version: Long = -1 +} + +typealias RealmSharedConfigBuilder = RealmConfiguration.Builder + +fun openRealmWith( + schemas: Set, + configuration: ((RealmSharedConfigBuilder) -> RealmSharedConfigBuilder)? = null +): Realm { + val latestSchema = requireNotNull(schemas.maxByOrNull { it.version }) { "At least one schema is required!" } + + return Realm.open( + RealmConfiguration.Builder(latestSchema.classes + LatestManualMigration::class) + .schemaVersion(latestSchema.version) + .let { + configuration?.invoke(it) ?: it + } + .build() + ).also { realm -> + val latestManualMigration = realm.query().first().find() ?: run { + realm.writeBlocking { + copyToRealm( + LatestManualMigration().apply { + version = -1 + } + ) + } + } + + val migrationStartedFrom = latestManualMigration.version + + schemas.sortedBy { it.version }.forEach { + if (it.version > latestManualMigration.version) { + realm.writeBlocking { + it.migrateOrInitialize?.invoke(this, migrationStartedFrom) + + findLatest(latestManualMigration)?.version = it.version + } + } + } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/Delegates.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/Delegates.kt new file mode 100644 index 00000000..7155aa69 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/Delegates.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities + +import de.gematik.ti.erp.app.fhir.parser.asFormattedString +import de.gematik.ti.erp.app.fhir.parser.asTemporalAccessor +import java.lang.IllegalArgumentException +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KMutableProperty +import kotlin.reflect.KProperty +import org.bouncycastle.util.encoders.Base64 +import java.time.temporal.TemporalAccessor + +inline fun > enumName(backingProperty: KMutableProperty) = + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T = + enumValueOf(backingProperty.getter.call()) + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + backingProperty.setter.call(value.name) + } + } + +inline fun > enumName(backingProperty: KMutableProperty, defaultValue: T) = + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T = + try { + enumValueOf(backingProperty.getter.call()) + } catch (_: IllegalArgumentException) { + defaultValue + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + backingProperty.setter.call(value.name) + } + } + +fun byteArrayBase64(backingProperty: KMutableProperty) = + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray = + Base64.decode(backingProperty.getter.call()) + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: ByteArray) { + backingProperty.setter.call(Base64.toBase64String(value)) + } + } + +fun byteArrayBase64Nullable(backingProperty: KMutableProperty) = + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): ByteArray? = + backingProperty.getter.call()?.let { Base64.decode(it) } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: ByteArray?) { + backingProperty.setter.call(value?.let { Base64.toBase64String(it) }) + } + } + +fun temporalAccessorNullable(backingProperty: KMutableProperty) = + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): TemporalAccessor? = + backingProperty.getter.call()?.asTemporalAccessor() + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: TemporalAccessor?) { + backingProperty.setter.call(value?.asFormattedString()) + } + } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/EntityUtils.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/EntityUtils.kt new file mode 100644 index 00000000..a9acac6f --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/EntityUtils.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities + +import io.realm.kotlin.Deleteable +import io.realm.kotlin.MutableRealm +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject + +typealias Adjacent = Iterator + +interface Cascading : Deleteable { + fun objectsToFollow(): Iterator + + /** + * Returns objects in reversed order; i.e. the most inner objects will be yielded first. + */ + fun flatten(maxDepth: Int = Int.MAX_VALUE): Adjacent { + require(maxDepth >= 0) + return iterator { + flatten(this@Cascading, 0, maxDepth) + } + } +} + +fun Adjacent.objectIterator(): Iterator = + iterator { + this@objectIterator.forEach { obj -> + when (obj) { + is RealmList<*> -> { + obj.forEach { + (it as? RealmObject)?.run { yield(it) } + } + } + is RealmObject -> + yield(obj) + } + } + } + +private suspend fun SequenceScope.flatten( + currentObject: Cascading, + currentDepth: Int, + maxDepth: Int +) { + if (currentDepth < maxDepth) { + currentObject.objectsToFollow().forEach { obj -> + when (obj) { + is RealmList<*> -> { + obj.forEach { entry -> + if (entry is Cascading) { + flatten(entry, currentDepth + 1, maxDepth) + } + } + } + is Cascading -> { + flatten(obj, currentDepth + 1, maxDepth) + } + } + } + } + yieldAll(currentObject.objectsToFollow()) +} + +fun MutableRealm.deleteAll(cascading: Cascading, maxDepth: Int = Int.MAX_VALUE) { + cascading.flatten(maxDepth = maxDepth).forEachRemaining { + delete(it) + } + delete(cascading) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIAuditEvent.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Address.kt similarity index 74% rename from android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIAuditEvent.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Address.kt index 421fcd4f..4c90324f 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIAuditEvent.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Address.kt @@ -16,14 +16,12 @@ * */ -package de.gematik.ti.erp.app.prescription.detail.ui.model +package de.gematik.ti.erp.app.db.entities.v1 -import java.time.LocalDateTime +import io.realm.kotlin.types.RealmObject -data class UIAuditEvent( - val id: String, - val locale: String, - val text: String?, - val timestamp: LocalDateTime, - val taskId: String -) +class AddressEntityV1 : RealmObject { + var line1: String = "" + var line2: String = "" + var postalCodeAndCity: String = "" +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/AuditEvent.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/AuditEvent.kt new file mode 100644 index 00000000..5918a551 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/AuditEvent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject + +class AuditEventEntityV1 : RealmObject { + var id: String = "" + var taskId: String? = null + var text: String = "" + var timestamp: RealmInstant = RealmInstant.MIN + + var profile: ProfileEntityV1? = null +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/IdpAuthenticationData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/IdpAuthenticationData.kt new file mode 100644 index 00000000..4fe1f3f9 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/IdpAuthenticationData.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.entities.byteArrayBase64Nullable +import de.gematik.ti.erp.app.db.entities.enumName +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore + +enum class SingleSignOnTokenScopeV1 { + Default, + AlternateAuthentication, + ExternalAuthentication +} + +class IdpAuthenticationDataEntityV1 : RealmObject { + var singleSignOnToken: String? = null + + var _singleSignOnTokenScope: String = SingleSignOnTokenScopeV1.Default.toString() + + @delegate:Ignore + var singleSignOnTokenScope: SingleSignOnTokenScopeV1 by enumName(::_singleSignOnTokenScope) + + var cardAccessNumber: String = "" + + var _healthCardCertificate: String? = null + + @delegate:Ignore + var healthCardCertificate: ByteArray? by byteArrayBase64Nullable(::_healthCardCertificate) + + var _aliasOfSecureElementEntry: String? = null + + @delegate:Ignore + var aliasOfSecureElementEntry: ByteArray? by byteArrayBase64Nullable(::_aliasOfSecureElementEntry) + + var externalAuthenticatorId: String? = null + var externalAuthenticatorName: String? = null +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/IdpConfiguration.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/IdpConfiguration.kt new file mode 100644 index 00000000..f86d3ace --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/IdpConfiguration.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.entities.byteArrayBase64 +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore + +class IdpConfigurationEntityV1 : RealmObject { + var authorizationEndpoint: String = "" + var ssoEndpoint: String = "" + var tokenEndpoint: String = "" + var pairingEndpoint: String = "" + var authenticationEndpoint: String = "" + var pukIdpEncEndpoint: String = "" + var pukIdpSigEndpoint: String = "" + + var _certificateX509Base64: String = "" + + @delegate:Ignore + var certificateX509: ByteArray by byteArrayBase64(::_certificateX509Base64) + + var expirationTimestamp: RealmInstant = RealmInstant.MIN + var issueTimestamp: RealmInstant = RealmInstant.MIN + + var externalAuthorizationIDsEndpoint: String? = null + var thirdPartyAuthorizationEndpoint: String? = null +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/PharmacyCache.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/PharmacyCache.kt new file mode 100644 index 00000000..c8e39021 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/PharmacyCache.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import io.realm.kotlin.types.RealmObject + +class PharmacyCacheEntityV1 : RealmObject { + var telematikId: String = "" + var name: String = "" +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt new file mode 100644 index 00000000..e0aa6851 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.byteArrayBase64Nullable +import de.gematik.ti.erp.app.db.entities.enumName +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore +import io.realm.kotlin.ext.realmListOf +import io.realm.kotlin.types.annotations.PrimaryKey +import java.util.UUID + +enum class ProfileColorNamesV1 { + SPRING_GRAY, + SUN_DEW, + PINK, + TREE, + BLUE_MOON +} + +// tag::ProfileEntity[] + +// Info: don't change any figure name because names are saved into database +enum class AvatarFigureV1 { + PersonalizedImage, + FemaleDoctor, + WomanWithHeadScarf, + Grandfather, + BoyWithHealthCard, + OldManOfColor, + WomanWithPhone, + Grandmother, + ManWithPhone, + WheelchairUser, + Baby, + MaleDoctorWithPhone, + FemaleDoctorWithPhone, + FemaleDeveloper +} + +class ProfileEntityV1 : RealmObject, Cascading { + + @PrimaryKey + var id: String = UUID.randomUUID().toString() + + var name: String = "" + + var _avatarFigure: String = AvatarFigureV1.PersonalizedImage.toString() + + @delegate:Ignore + var avatarFigure: AvatarFigureV1 by enumName(::_avatarFigure) + + var _colorName: String = ProfileColorNamesV1.SPRING_GRAY.toString() + + @delegate:Ignore + var color: ProfileColorNamesV1 by enumName(::_colorName) + + var _personalizedImage: String? = null + + @delegate:Ignore + var personalizedImage: ByteArray? by byteArrayBase64Nullable(::_personalizedImage) + + var insurantName: String? = null + var insuranceIdentifier: String? = null + var insuranceName: String? = null + + var lastAuthenticated: RealmInstant? = null + var lastAuditEventSynced: RealmInstant? = null + var lastTaskSynced: RealmInstant? = null + + var active: Boolean = false + + var syncedTasks: RealmList = realmListOf() + var scannedTasks: RealmList = realmListOf() + + var idpAuthenticationData: IdpAuthenticationDataEntityV1? = null + var auditEvents: RealmList = realmListOf() + + override fun objectsToFollow(): Iterator = + iterator { + yield(syncedTasks) + yield(scannedTasks) + yield(auditEvents) + idpAuthenticationData?.let { yield(it) } + } +} +// end::ProfileEntity[] diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/SafetynetAttestation.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/SafetynetAttestation.kt new file mode 100644 index 00000000..7ddee9b8 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/SafetynetAttestation.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.entities.byteArrayBase64 +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore + +class SafetynetAttestationEntityV1 : RealmObject { + var jws: String = "" + var _ourNonceBase64: String = "" + + @delegate:Ignore + var ourNonce: ByteArray by byteArrayBase64(::_ourNonceBase64) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Settings.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Settings.kt new file mode 100644 index 00000000..304a603e --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Settings.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.byteArrayBase64 +import de.gematik.ti.erp.app.db.entities.enumName +import de.gematik.ti.erp.app.db.entities.v1.SettingsAuthenticationMethodV1.Unspecified +import de.gematik.ti.erp.app.db.toRealmInstant +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore +import java.time.LocalDateTime + +enum class SettingsAuthenticationMethodV1 { + HealthCard, + DeviceSecurity, + Password, + Unspecified, + + @Deprecated("Keep for older app versions migrating to a newer one with mandatory app protection.") + Biometrics, + + @Deprecated("Keep for older app versions migrating to a newer one with mandatory app protection.") + DeviceCredentials, + + @Deprecated("Keep for older app versions migrating to a newer one with mandatory app protection.") + None +} + +class PasswordEntityV1 : RealmObject { + var _salt: String = "" + + @delegate:Ignore + var salt: ByteArray by byteArrayBase64(::_salt) + + var _hash: String = "" + + @delegate:Ignore + var hash: ByteArray by byteArrayBase64(::_hash) + + fun reset() { + _salt = "" + _hash = "" + } +} + +class PharmacySearchEntityV1 : RealmObject { + var name: String = "" + var locationEnabled: Boolean = false + var filterReady: Boolean = false + var filterDeliveryService: Boolean = false + var filterOnlineService: Boolean = false + var filterOpenNow: Boolean = false +} + +class SettingsEntityV1 : RealmObject, Cascading { + var _authenticationMethod: String = Unspecified.toString() + + @delegate:Ignore + var authenticationMethod: SettingsAuthenticationMethodV1 by enumName(::_authenticationMethod) + + var authenticationFails: Int = 0 + var zoomEnabled: Boolean = false + var welcomeDrawerShown: Boolean = false + + var pharmacySearch: PharmacySearchEntityV1? = PharmacySearchEntityV1() + + var userHasAcceptedInsecureDevice: Boolean = false + var dataProtectionVersionAccepted: RealmInstant = LocalDateTime.of(2021, 10, 15, 0, 0).toRealmInstant() + + var password: PasswordEntityV1? = PasswordEntityV1() + + var latestAppVersionName: String = "" + var latestAppVersionCode: Int = -1 + + var onboardingLatestAppVersionName: String = "" + var onboardingLatestAppVersionCode: Int = -1 + + var shippingContact: ShippingContactEntityV1? = null + + override fun objectsToFollow(): Iterator = + iterator { + pharmacySearch?.let { yield(it) } + password?.let { yield(it) } + shippingContact?.let { yield(it) } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/TruststoreDao.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/ShippingContact.kt similarity index 56% rename from android/src/main/java/de/gematik/ti/erp/app/db/daos/TruststoreDao.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/ShippingContact.kt index d8007b5e..5c0b9112 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/TruststoreDao.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/ShippingContact.kt @@ -16,22 +16,21 @@ * */ -package de.gematik.ti.erp.app.db.daos +package de.gematik.ti.erp.app.db.entities.v1 -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.TruststoreEntity +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmObject -@Dao -interface TruststoreDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(truststoreEntity: TruststoreEntity) +class ShippingContactEntityV1 : RealmObject, Cascading { + var address: AddressEntityV1? = AddressEntityV1() + var name: String = "" + var telephoneNumber: String = "" + var mail: String = "" + var deliveryInformation: String = "" - @Query("SELECT * FROM truststore") - suspend fun getUntrusted(): TruststoreEntity? - - @Query("DELETE FROM truststore") - suspend fun deleteAll() + override fun objectsToFollow(): Iterator = + iterator { + address?.let { yield(it) } + } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Truststore.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Truststore.kt new file mode 100644 index 00000000..538f881d --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Truststore.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import io.realm.kotlin.types.RealmObject + +class TruststoreEntityV1 : RealmObject { + var certListJson: String = "" + var ocspListJson: String = "" +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Communication.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Communication.kt new file mode 100644 index 00000000..c37b21b2 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Communication.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import de.gematik.ti.erp.app.db.entities.enumName +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore + +enum class CommunicationProfileV1 { + ErxCommunicationDispReq, ErxCommunicationReply, Unknown +} + +class CommunicationEntityV1 : RealmObject { + var taskId: String = "" + var communicationId: String = "" + + var orderId: String = "" + + var _profile: String = CommunicationProfileV1.ErxCommunicationDispReq.toString() + + @delegate:Ignore + var profile: CommunicationProfileV1 by enumName(::_profile) + + var sentOn: RealmInstant = RealmInstant.MIN + var sender: String = "" + var recipient: String = "" + var payload: String? = null + + var consumed: Boolean = false + + // back reference + var parent: SyncedTaskEntityV1? = null +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/UIPrescriptionOrder.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/FavoritePharmacy.kt similarity index 71% rename from android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/UIPrescriptionOrder.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/FavoritePharmacy.kt index 7a36d87b..6b280d82 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/model/UIPrescriptionOrder.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/FavoritePharmacy.kt @@ -16,15 +16,14 @@ * */ -package de.gematik.ti.erp.app.pharmacy.usecase.model +package de.gematik.ti.erp.app.db.entities.v1.task -data class UIPrescriptionOrder( - val taskId: String, - val title: String?, - val substitutionsAllowed: Boolean, - val accessCode: String -) { - var selected: Boolean = true +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject + +class FavoritePharmacyEntityV1 : RealmObject { + var telematikId: String = "" + var lastUsed: RealmInstant = RealmInstant.MIN + var pharmacyName: String = "" var address: String = "" - var patientName: String = "" } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ingredient.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ingredient.kt new file mode 100644 index 00000000..929fb5e1 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ingredient.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmObject + +class IngredientEntityV1 : RealmObject, Cascading { + var text: String = "" + var form: String? = null + var number: String? = null + var amount: String? = null + var strength: RatioEntityV1? = null + + override fun objectsToFollow(): Iterator = + iterator { + strength?.let { yield(it) } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/InsuranceInformation.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/InsuranceInformation.kt new file mode 100644 index 00000000..0eb709ac --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/InsuranceInformation.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import io.realm.kotlin.types.RealmObject + +class InsuranceInformationEntityV1 : RealmObject { + var name: String? = null + var statusCode: String? = null +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Medication.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Medication.kt new file mode 100644 index 00000000..dd597284 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Medication.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.enumName +import de.gematik.ti.erp.app.db.entities.temporalAccessorNullable +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore +import io.realm.kotlin.ext.realmListOf +import java.time.temporal.TemporalAccessor + +// https://simplifier.net/erezept/~resources?category=Profile&sortBy=RankScore_desc +// BTM = Betäubungsmittel, AMVV = Arzneimittelverschreibungsverordnung +enum class MedicationCategoryV1 { + ARZNEI_UND_VERBAND_MITTEL, + BTM, + AMVV +} + +enum class MedicationProfileV1 { + PZN, COMPOUNDING, INGREDIENT, FREETEXT +} + +class MedicationEntityV1 : RealmObject, Cascading { + var text: String = "" + var _medicationProfile: String = MedicationProfileV1.PZN.toString() + + @delegate:Ignore + var medicationProfile: MedicationProfileV1 by enumName(::_medicationProfile) + var _medicationCategory: String = MedicationCategoryV1.ARZNEI_UND_VERBAND_MITTEL.toString() + + @delegate:Ignore + var medicationCategory: MedicationCategoryV1 by enumName(::_medicationCategory) + var form: String? = null + var amount: RatioEntityV1? = null + var vaccine: Boolean = false + var manufacturingInstructions: String? = null + var packaging: String? = null + var normSizeCode: String? = null + var uniqueIdentifier: String? = null // PZN + var lotNumber: String? = null + + var _expirationDate: String? = null + + @delegate:Ignore + var expirationDate: TemporalAccessor? by temporalAccessorNullable(::_expirationDate) + + var ingredients: RealmList = realmListOf() + + override fun objectsToFollow(): Iterator = + iterator { + yield(ingredients) + amount?.let { yield(it) } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/NavigationObservable.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt similarity index 50% rename from android/src/main/java/de/gematik/ti/erp/app/di/NavigationObservable.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt index fb091632..39718539 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/di/NavigationObservable.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationDispense.kt @@ -16,24 +16,23 @@ * */ -package de.gematik.ti.erp.app.di +package de.gematik.ti.erp.app.db.entities.v1.task -import android.os.Bundle -import androidx.annotation.IdRes -import androidx.navigation.NavController -import dagger.hilt.android.scopes.ActivityRetainedScoped -import kotlinx.coroutines.flow.MutableSharedFlow -import javax.inject.Inject +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject -@ActivityRetainedScoped -class NavigationObservable @Inject constructor() { - val navigationEventLane: MutableSharedFlow Any> = MutableSharedFlow() +class MedicationDispenseEntityV1 : RealmObject, Cascading { + var dispenseId: String = "" + var patientIdentifier: String = "" // KVNR + var medication: MedicationEntityV1? = null + var wasSubstituted: Boolean = false + var dosageInstruction: String? = null + var performer: String = "" // Telematik-ID + var whenHandedOver: RealmInstant = RealmInstant.MIN - suspend fun navigateTo(@IdRes navigationId: Int, args: Bundle?) { - withNavController { navigate(navigationId, args) } - } - - suspend fun withNavController(block: NavController.() -> Any) { - navigationEventLane.emit(block) + override fun objectsToFollow(): Iterator = iterator { + medication?.let { yield(it) } } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt new file mode 100644 index 00000000..c4915a5f --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject + +@Suppress("LongParameterList") +class MedicationRequestEntityV1( + var medication: MedicationEntityV1?, + var dateOfAccident: RealmInstant?, // unfalltag + var location: String?, // unfallbetrieb + var emergencyFee: Boolean?, // emergency service fee = notfallgebuehr + var substitutionAllowed: Boolean, + var dosageInstruction: String?, + var note: String?, + var multiplePrescriptionInfo: MultiplePrescriptionInfoEntityV1?, + var bvg: Boolean, + var additionalFee: String? +) : RealmObject, Cascading { + constructor() : this( + medication = null, + dateOfAccident = null, + location = null, + emergencyFee = null, + substitutionAllowed = false, + dosageInstruction = null, + note = null, + multiplePrescriptionInfo = null, + bvg = false, + additionalFee = null + ) + + override fun objectsToFollow(): Iterator = iterator { + medication?.let { yield(it) } + multiplePrescriptionInfo?.let { yield(it) } + } +} diff --git a/android/src/release/java/de/gematik/ti/erp/app/di/ReleaseHeadersModule.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MultiplePrescription.kt similarity index 55% rename from android/src/release/java/de/gematik/ti/erp/app/di/ReleaseHeadersModule.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MultiplePrescription.kt index 26980b70..8bedeea0 100644 --- a/android/src/release/java/de/gematik/ti/erp/app/di/ReleaseHeadersModule.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MultiplePrescription.kt @@ -16,25 +16,21 @@ * */ -package de.gematik.ti.erp.app.di +package de.gematik.ti.erp.app.db.entities.v1.task -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.gematik.ti.erp.app.BuildKonfig -import okhttp3.Interceptor +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject -@InstallIn(SingletonComponent::class) -@Module -object ReleaseHeadersModule { +class MultiplePrescriptionInfoEntityV1( + var indicator: Boolean, + var numbering: RatioEntityV1?, + var start: RealmInstant? +) : RealmObject, Cascading { + constructor() : this(indicator = false, numbering = null, start = RealmInstant.MIN) - @DevelopReleaseHeaderInterceptor - @Provides - fun providesHeaderInterceptor(): Interceptor = Interceptor { chain -> - chain.proceed( - chain.request().newBuilder() - .build() - ) + override fun objectsToFollow(): Iterator = iterator { + numbering?.let { yield(it) } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/LowDetailEventSimple.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/OftenUsedPharmacy.kt similarity index 66% rename from android/src/main/java/de/gematik/ti/erp/app/db/entities/LowDetailEventSimple.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/OftenUsedPharmacy.kt index fa839bf7..014ae34d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/LowDetailEventSimple.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/OftenUsedPharmacy.kt @@ -16,18 +16,15 @@ * */ -package de.gematik.ti.erp.app.db.entities +package de.gematik.ti.erp.app.db.entities.v1.task -import androidx.room.Entity -import androidx.room.PrimaryKey -import java.time.OffsetDateTime +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject -@Entity(tableName = "lowDetailEvents") -data class LowDetailEventSimple( - val text: String, - val timestamp: OffsetDateTime, - val taskId: String -) { - @PrimaryKey(autoGenerate = true) - var id: Long = 0 +class OftenUsedPharmacyEntityV1 : RealmObject { + var telematikId: String = "" + var lastUsed: RealmInstant = RealmInstant.MIN + var usageCount: Int = 0 + var pharmacyName: String = "" + var address: String = "" } diff --git a/android/src/main/java/de/gematik/ti/erp/app/interceptor/RetryInterceptor.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Organization.kt similarity index 54% rename from android/src/main/java/de/gematik/ti/erp/app/interceptor/RetryInterceptor.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Organization.kt index 6780aa87..146b33d6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/interceptor/RetryInterceptor.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Organization.kt @@ -16,25 +16,22 @@ * */ -package de.gematik.ti.erp.app.interceptor +package de.gematik.ti.erp.app.db.entities.v1.task -import okhttp3.Interceptor -import okhttp3.Response -import timber.log.Timber +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmObject -private const val MAX_RETRY_COUNT = 3 +class OrganizationEntityV1 : RealmObject, Cascading { + var name: String? = null + var address: AddressEntityV1? = null + var uniqueIdentifier: String? = null // BSNR + var phone: String? = null + var mail: String? = null -class RetryInterceptor : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - var response = chain.proceed(request) - var tryCount = 0 - while (!response.isSuccessful && tryCount < MAX_RETRY_COUNT) { - Timber.d("Request didn't succeed - $tryCount") - tryCount++ - response = chain.proceed(request) + override fun objectsToFollow(): Iterator = + iterator { + address?.let { yield(it) } } - return response - } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/daos/AttestationDao.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Patient.kt similarity index 53% rename from android/src/main/java/de/gematik/ti/erp/app/db/daos/AttestationDao.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Patient.kt index 40029463..f2a553b4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/AttestationDao.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Patient.kt @@ -16,21 +16,22 @@ * */ -package de.gematik.ti.erp.app.db.daos +package de.gematik.ti.erp.app.db.entities.v1.task -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.SafetynetAttestationEntity -import kotlinx.coroutines.flow.Flow +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject -@Dao -interface AttestationDao { +class PatientEntityV1 : RealmObject, Cascading { + var name: String? = null + var address: AddressEntityV1? = null + var birthdate: RealmInstant? = null + var insuranceIdentifier: String? = null - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAttestation(attestationEntity: SafetynetAttestationEntity) - - @Query("SELECT * FROM safetynetattestations") - fun getAllAttestations(): Flow> + override fun objectsToFollow(): Iterator = + iterator { + address?.let { yield(it) } + } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Practitioner.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Practitioner.kt new file mode 100644 index 00000000..c3c3338d --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Practitioner.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import io.realm.kotlin.types.RealmObject + +class PractitionerEntityV1 : RealmObject { + var name: String? = null + var qualification: String? = null + var practitionerIdentifier: String? = null // code == LANR (long term practitioner id) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Quantity.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Quantity.kt new file mode 100644 index 00000000..ced4632c --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Quantity.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import io.realm.kotlin.types.RealmObject + +class QuantityEntityV1 : RealmObject { + var value: String = "" + var unit: String = "" +} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/StatusObject.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ratio.kt similarity index 59% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/StatusObject.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ratio.kt index 5aadb8d3..09763bce 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/StatusObject.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ratio.kt @@ -16,19 +16,19 @@ * */ -package de.gematik.ti.erp.app.nfc.model.tagobjects +package de.gematik.ti.erp.app.db.entities.v1.task -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmObject -private const val DO_99_TAG = 0x19 +class RatioEntityV1 : RealmObject, Cascading { + var numerator: QuantityEntityV1? = null + var denominator: QuantityEntityV1? = null -/** - * Status object with TAG 99 - * - * @param statusBytes byte array with extracted response status from encrypted ResponseApdu - */ -class StatusObject(private val statusBytes: ByteArray) { - val taggedObject: DERTaggedObject = - DERTaggedObject(false, DO_99_TAG, DEROctetString(statusBytes)) + override fun objectsToFollow(): Iterator = + iterator { + numerator?.let { yield(it) } + denominator?.let { yield(it) } + } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/converter/CertificateConverter.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/ScannedTask.kt similarity index 61% rename from android/src/main/java/de/gematik/ti/erp/app/db/converter/CertificateConverter.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/ScannedTask.kt index 21021b38..e6c995be 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/converter/CertificateConverter.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/ScannedTask.kt @@ -16,20 +16,18 @@ * */ -package de.gematik.ti.erp.app.db.converter +package de.gematik.ti.erp.app.db.entities.v1.task -import androidx.room.TypeConverter -import org.bouncycastle.cert.X509CertificateHolder +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject -class CertificateConverter { +class ScannedTaskEntityV1 : RealmObject { + var taskId: String = "" + var accessCode: String = "" + var scannedOn: RealmInstant = RealmInstant.MIN + var redeemedOn: RealmInstant? = null - @TypeConverter - fun toCertEncoding(certificateHolder: X509CertificateHolder): ByteArray? { - return certificateHolder.encoded - } - - @TypeConverter - fun fromCertEncoding(byteArray: ByteArray): X509CertificateHolder { - return X509CertificateHolder(byteArray) - } + // back reference + var parent: ProfileEntityV1? = null } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/SyncedTask.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/SyncedTask.kt new file mode 100644 index 00000000..f5e0fd44 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/SyncedTask.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1.task + +import de.gematik.ti.erp.app.db.entities.Cascading +import de.gematik.ti.erp.app.db.entities.enumName +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.Ignore +import io.realm.kotlin.ext.realmListOf + +enum class TaskStatusV1 { + Ready, InProgress, Completed, Other, Draft, Requested, Received, Accepted, Rejected, Canceled, OnHold, Failed; +} + +class SyncedTaskEntityV1 : RealmObject, Cascading { + // Task Entities + + var taskId: String = "" + var accessCode: String? = null + var lastModified: RealmInstant = RealmInstant.MIN + + var expiresOn: RealmInstant? = null + var acceptUntil: RealmInstant? = null + var authoredOn: RealmInstant = RealmInstant.MIN + + // KBV Bundle Entities + + var organization: OrganizationEntityV1? = null // an organization can contain multiple authors + var practitioner: PractitionerEntityV1? = null + var patient: PatientEntityV1? = null + var insuranceInformation: InsuranceInformationEntityV1? = null + + var _status: String = TaskStatusV1.Other.toString() + + @delegate:Ignore + var status: TaskStatusV1 by enumName(::_status) + + var medicationRequest: MedicationRequestEntityV1? = null + var medicationDispenses: RealmList = realmListOf() + + var communications: RealmList = realmListOf() + + // back reference + var parent: ProfileEntityV1? = null + + var isIncomplete: Boolean = false + var pvsIdentifier: String = "" + var failureToReport: String = "" + + override fun objectsToFollow(): Iterator = + iterator { + organization?.let { yield(it) } + practitioner?.let { yield(it) } + patient?.let { yield(it) } + insuranceInformation?.let { yield(it) } + medicationRequest?.let { yield(it) } + yield(medicationDispenses) + yield(communications) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/di/JWSConverterFactory.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/di/JWSConverterFactory.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/di/JWSConverterFactory.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/di/JWSConverterFactory.kt diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/AuditEventMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/AuditEventMapper.kt new file mode 100644 index 00000000..7465562c --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/AuditEventMapper.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asInstant +import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedArrayOrNull +import de.gematik.ti.erp.app.fhir.parser.containedString +import de.gematik.ti.erp.app.fhir.parser.filterWith +import de.gematik.ti.erp.app.fhir.parser.findAll +import de.gematik.ti.erp.app.fhir.parser.profileValue +import de.gematik.ti.erp.app.fhir.parser.stringValue +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.time.Instant + +fun extractAuditEvents( + bundle: JsonElement, + save: (id: String, taskId: String?, description: String, timestamp: Instant) -> Unit +): Int { + val bundleTotal = bundle.containedArrayOrNull("entry")?.size ?: 0 + val resources = bundle + .findAll(listOf("entry", "resource")) + .filterWith( + "meta.profile", + profileValue("https://gematik.de/fhir/StructureDefinition/ErxAuditEvent", "1.1.1") + ) + + resources.forEach { resource -> + val id = resource.containedString("id") + val text = resource.contained("text").containedString("div") + val taskId = resource + .findAll(listOf("entity", "what", "identifier")) + .filterWith("system", stringValue("https://gematik.de/fhir/NamingSystem/PrescriptionID")) + .firstOrNull() + ?.containedString("value") + + val timestamp = requireNotNull(resource.contained("recorded").jsonPrimitive.asInstant()) { + "Audit event field `recorded` missing" + } + + val description = text.removeSurrounding("
    ", "
    ") + + save(id, taskId, description, timestamp) + } + + return bundleTotal +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommonPharmacyTimes.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommonPharmacyTimes.kt new file mode 100644 index 00000000..39e28f49 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommonPharmacyTimes.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asLocalTime +import kotlinx.serialization.json.JsonPrimitive +import java.time.LocalTime + +private val CommonPharmacyTimes: Map by lazy { + mapOf( + "00:00:00" to LocalTime.of(0, 0), + "00:30:00" to LocalTime.of(0, 30), + "01:00:00" to LocalTime.of(1, 0), + "01:30:00" to LocalTime.of(1, 30), + "02:00:00" to LocalTime.of(2, 0), + "02:30:00" to LocalTime.of(2, 30), + "03:00:00" to LocalTime.of(3, 0), + "03:30:00" to LocalTime.of(3, 30), + "04:00:00" to LocalTime.of(4, 0), + "04:30:00" to LocalTime.of(4, 30), + "05:00:00" to LocalTime.of(5, 0), + "05:30:00" to LocalTime.of(5, 30), + "06:00:00" to LocalTime.of(6, 0), + "06:30:00" to LocalTime.of(6, 30), + "07:00:00" to LocalTime.of(7, 0), + "07:30:00" to LocalTime.of(7, 30), + "08:00:00" to LocalTime.of(8, 0), + "08:30:00" to LocalTime.of(8, 30), + "09:00:00" to LocalTime.of(9, 0), + "09:30:00" to LocalTime.of(9, 30), + "10:00:00" to LocalTime.of(10, 0), + "10:30:00" to LocalTime.of(10, 30), + "11:00:00" to LocalTime.of(11, 0), + "11:30:00" to LocalTime.of(11, 30), + "12:00:00" to LocalTime.of(12, 0), + "12:30:00" to LocalTime.of(12, 30), + "13:00:00" to LocalTime.of(13, 0), + "13:30:00" to LocalTime.of(13, 30), + "14:00:00" to LocalTime.of(14, 0), + "14:30:00" to LocalTime.of(14, 30), + "15:00:00" to LocalTime.of(15, 0), + "15:30:00" to LocalTime.of(15, 30), + "16:00:00" to LocalTime.of(16, 0), + "16:30:00" to LocalTime.of(16, 30), + "17:00:00" to LocalTime.of(17, 0), + "17:30:00" to LocalTime.of(17, 30), + "18:00:00" to LocalTime.of(18, 0), + "18:30:00" to LocalTime.of(18, 30), + "19:00:00" to LocalTime.of(19, 0), + "19:30:00" to LocalTime.of(19, 30), + "20:00:00" to LocalTime.of(20, 0), + "20:30:00" to LocalTime.of(20, 30), + "21:00:00" to LocalTime.of(21, 0), + "21:30:00" to LocalTime.of(21, 30), + "22:00:00" to LocalTime.of(22, 0), + "22:30:00" to LocalTime.of(22, 30), + "23:00:00" to LocalTime.of(23, 0), + "23:30:00" to LocalTime.of(23, 30) + ) +} + +fun lookupTime(tm: JsonPrimitive?): LocalTime? = + tm?.let { CommonPharmacyTimes[it.content] ?: it.asLocalTime() } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationMapper.kt new file mode 100644 index 00000000..1e16d92c --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationMapper.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asInstant +import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedArrayOrNull +import de.gematik.ti.erp.app.fhir.parser.containedString +import de.gematik.ti.erp.app.fhir.parser.containedStringOrNull +import de.gematik.ti.erp.app.fhir.parser.filterWith +import de.gematik.ti.erp.app.fhir.parser.findAll +import de.gematik.ti.erp.app.fhir.parser.profileValue +import de.gematik.ti.erp.app.fhir.parser.stringValue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import java.time.Instant + +private fun template( + orderId: String, + reference: String, + payload: String, + recipientTID: String +) = """ +{ + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" + ] + }, + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/OrderID", + "value": $orderId + } + ], + "status": "unknown", + "basedOn": [ + { + "reference": $reference + } + ], + "recipient": [ + { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": $recipientTID + } + } + ], + "payload": [ + { + "contentString": $payload + } + ] +} +""".trimIndent() + +val json = Json { + encodeDefaults = true + prettyPrint = false +} + +fun createCommunicationDispenseRequest( + orderId: String, + taskId: String, + accessCode: String, + recipientTID: String, + payload: CommunicationPayload +): JsonElement { + val payloadString = json.encodeToString(payload) + val reference = "Task/$taskId/\$accept?ac=$accessCode" + + val templateString = template( + orderId = JsonPrimitive(orderId).toString(), + reference = JsonPrimitive(reference).toString(), + recipientTID = JsonPrimitive(recipientTID).toString(), + payload = JsonPrimitive(payloadString).toString() + ) + + return json.parseToJsonElement(templateString) +} + +enum class CommunicationProfile { + ErxCommunicationDispReq, ErxCommunicationReply +} + +fun extractCommunications( + bundle: JsonElement, + save: ( + taskId: String, + communicationId: String, + orderId: String?, + profile: CommunicationProfile, + sentOn: Instant, + sender: String, + recipient: String, + payload: String? + ) -> Unit +): Int { + val bundleTotal = bundle.containedArrayOrNull("entry")?.size ?: 0 + val resources = bundle + .findAll("entry.resource") + + resources.forEach { resource -> + val profileString = resource + .contained("meta") + .contained("profile") + .contained() + + val profile = when { + profileValue("https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq").invoke(profileString) -> + CommunicationProfile.ErxCommunicationDispReq + + // without profile versiob + profileValue( + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply" + ).invoke(profileString) -> + CommunicationProfile.ErxCommunicationReply + + profileValue( + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply", + "1.1.1" + ).invoke(profileString) -> + CommunicationProfile.ErxCommunicationReply + + else -> error("Unknown communication profile $profileString") + } + + val reference = resource.contained("basedOn").containedString("reference") + val taskId = reference.split("/", limit = 3)[1] // Task/160.000.000.036.519.13/$accept?ac=... + + val orderId = resource + .findAll("identifier") + .filterWith("system", stringValue("https://gematik.de/fhir/NamingSystem/OrderID")) + .firstOrNull() + ?.containedString("value") + + val communicationId = resource.containedString("id") + + val sentOn = requireNotNull(resource.contained("sent").jsonPrimitive.asInstant()) { + "Communication `sent` field missing" + } + + val sender = resource + .contained("sender") + .contained("identifier") + .containedString("value") + + val recipient = resource + .contained("recipient") + .contained("identifier") + .containedString("value") + + val payload = resource + .contained("payload") + .containedStringOrNull("contentString") + + save( + taskId, + communicationId, + orderId, + profile, + sentOn, + sender, + recipient, + payload + ) + } + + return bundleTotal +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationModel.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationModel.kt new file mode 100644 index 00000000..e8599d44 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationModel.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CommunicationPayload( + val version: String = "1", + val supplyOptionsType: String, + val name: String, + val address: List, + val hint: String = "", + val phone: String? +) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt new file mode 100644 index 00000000..1fb34744 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapper.kt @@ -0,0 +1,680 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asLocalDate +import de.gematik.ti.erp.app.fhir.parser.asTemporalAccessor +import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedArray +import de.gematik.ti.erp.app.fhir.parser.containedArrayOrNull +import de.gematik.ti.erp.app.fhir.parser.containedBoolean +import de.gematik.ti.erp.app.fhir.parser.containedOrNull +import de.gematik.ti.erp.app.fhir.parser.containedString +import de.gematik.ti.erp.app.fhir.parser.containedStringOrNull +import de.gematik.ti.erp.app.fhir.parser.filterWith +import de.gematik.ti.erp.app.fhir.parser.findAll +import de.gematik.ti.erp.app.fhir.parser.isProfileValue +import de.gematik.ti.erp.app.fhir.parser.stringValue +import io.github.aakira.napier.Napier +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.time.temporal.TemporalAccessor + +typealias AddressFn = ( + line: List?, + postalCode: String?, + city: String? +) -> R + +typealias OrganizationFn = ( + name: String?, + address: Address, + uniqueIdentifier: String?, + phone: String?, + mail: String? +) -> R + +typealias PatientFn = ( + name: String?, + address: Address, + birthDate: LocalDate?, + insuranceIdentifier: String? +) -> R + +typealias PractitionerFn = ( + name: String?, + qualification: String?, + practitionerIdentifier: String? +) -> R + +typealias InsuranceInformationFn = ( + name: String?, + statusCode: String? +) -> R + +typealias MedicationRequestFn = ( + dateOfAccident: LocalDate?, + location: String?, + emergencyFee: Boolean?, + substitutionAllowed: Boolean, + dosageInstruction: String?, + multiplePrescriptionInfo: MultiplePrescriptionInfo?, + note: String?, + bvg: Boolean, + additionalFee: String? +) -> R + +typealias MultiplePrescriptionInfoFn = ( + indicator: Boolean, + numbering: Ratio?, + start: LocalDate? +) -> R + +typealias MedicationFn = ( + text: String?, + medicationProfile: MedicationProfile, + medicationCategory: MedicationCategory, + form: String?, + amount: Ratio?, + vaccine: Boolean, + manufacturingInstructions: String?, + packaging: String?, + normSizeCode: String?, + uniqueIdentifier: String?, + ingredients: List, + lotNumber: String?, + expirationDate: TemporalAccessor? +) -> R + +typealias IngredientFn = ( + text: String, + form: String?, + number: String?, + amount: String?, + strength: Ratio? +) -> R + +typealias RatioFn = ( + numerator: Quantity?, + denominator: Quantity? +) -> R + +typealias QuantityFn = ( + value: String, + unit: String +) -> R + +enum class MedicationCategory { + ARZNEI_UND_VERBAND_MITTEL, + BTM, + AMVV +} + +enum class MedicationProfile { + PZN, COMPOUNDING, INGREDIENT, FREETEXT +} + +@Suppress("LongParameterList") +fun extractKBVBundle( + bundle: JsonElement, + processOrganization: OrganizationFn, + processPatient: PatientFn, + processPractitioner: PractitionerFn, + processInsuranceInformation: InsuranceInformationFn, + processAddress: AddressFn
    , + processMedication: MedicationFn, + processIngredient: IngredientFn, + processRatio: RatioFn, + processQuantity: QuantityFn, + processMultiplePrescriptionInfo: MultiplePrescriptionInfoFn, + processMedicationRequest: MedicationRequestFn, + + savePVSIdentifier: (pvsId: String?) -> Unit, + + save: ( + organization: Organization, + patient: Patient, + practitioner: Practitioner, + insuranceInformation: InsuranceInformation, + medication: Medication, + medicationRequest: MedicationRequest + ) -> Unit +) { + val pvsId = bundle + .findAll("entry.resource.author.identifier") + .filterWith("system", stringValue("https://fhir.kbv.de/NamingSystem/KBV_NS_FOR_Pruefnummer")) + .firstOrNull() + ?.containedString("value") + + savePVSIdentifier(pvsId) + + val resources = bundle + .findAll("entry.resource") + + var organization: Organization? = null + var patient: Patient? = null + var practitioner: Practitioner? = null + var insuranceInformation: InsuranceInformation? = null + var medication: Medication? = null + var medicationRequest: MedicationRequest? = null + + resources.forEach { resource -> + val profileString = resource + .contained("meta") + .contained("profile") + .contained() + + when { + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Organization", + "1.0.3" + ) -> { + organization = extractOrganization( + resource, + processOrganization, + processAddress + ) + } + + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient", + "1.0.3" + ) -> { + patient = extractPatient( + resource, + processPatient, + processAddress + ) + } + + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Practitioner", + "1.0.3" + ) -> { + practitioner = extractPractitioner( + resource, + processPractitioner + ) + } + + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Coverage", + "1.0.3" + ) -> { + insuranceInformation = extractInsuranceInformation( + resource, + processInsuranceInformation + ) + } + + profileString.isProfileValueOfMedication("1.0.2") + -> { + medication = extractMedication( + resource, + processMedication, + processIngredient, + processRatio, + processQuantity + ) + } + + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Prescription", + "1.0.2" + ) -> { + medicationRequest = extractMedicationRequest( + resource, + processMedicationRequest, + processMultiplePrescriptionInfo, + processRatio, + processQuantity + + ) + } + } + } + + save( + requireNotNull(organization), + requireNotNull(patient), + requireNotNull(practitioner), + requireNotNull(insuranceInformation), + requireNotNull(medication), + requireNotNull(medicationRequest) + ) +} + +fun extractOrganization( + resource: JsonElement, + processOrganization: OrganizationFn, + processAddress: AddressFn
    +): Organization { + val name = resource.containedStringOrNull("name") + + val telecom = resource.containedArrayOrNull("telecom") + + var phone: String? = null + var mail: String? = null + + telecom?.forEach { + when (it.containedString("system")) { + "phone" -> phone = it.containedStringOrNull("value") + "email" -> mail = it.containedStringOrNull("value") + } + } + + val bsnr = resource + .findAll("identifier") + .filterWith("system", stringValue("https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR")) + .firstOrNull() + ?.containedString("value") + + return processOrganization( + name, + resource.extractAddress(processAddress), + bsnr, + phone, + mail + ) +} + +fun extractMedicationRequest( + resource: JsonElement, + processMedicationRequest: MedicationRequestFn, + processMultiplePrescriptionInfo: MultiplePrescriptionInfoFn, + ratioFn: RatioFn, + quantityFn: QuantityFn +): MedicationRequest { + val dateOfAccident = resource + .findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident") + ).firstOrNull()?.findAll("extension")?.filterWith( + "url", + stringValue("unfalltag") + )?.firstOrNull()?.containedOrNull("valueDate")?.jsonPrimitive?.asLocalDate() + + val location = resource + .findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident") + ).firstOrNull()?.findAll("extension")?.filterWith( + "url", + stringValue("unfallbetrieb") + )?.firstOrNull() + ?.containedString("valueString") + + val emergencyFee = resource + .findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_EmergencyServicesFee") + ).firstOrNull()?.containedBoolean("valueBoolean") + + val substitutionAllowed = resource.contained("substitution").containedBoolean("allowedBoolean") + + val dosageInstruction = resource.containedOrNull("dosageInstruction")?.containedStringOrNull("text") + val multiplePrescriptionInfo = resource + .findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Multiple_Prescription") + ) + .first() + .extractMultiplePrescriptionInfo(processMultiplePrescriptionInfo, ratioFn, quantityFn) + val note = resource.containedOrNull("note")?.containedStringOrNull("text") + val bvg = resource + .findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_BVG") + ).firstOrNull()?.containedBoolean("valueBoolean") ?: false + val additionalFee = resource + .findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_StatusCoPayment") + ) + .firstOrNull() + ?.contained("valueCoding") + ?.containedString("code") + + return processMedicationRequest( + dateOfAccident, + location, + emergencyFee, + substitutionAllowed, + dosageInstruction, + multiplePrescriptionInfo, + note, + bvg, + additionalFee + ) +} + +fun extractPatient( + resource: JsonElement, + processPatient: PatientFn, + processAddress: AddressFn
    +): Patient { + val name = resource.extractHumanName() + + val birthDate = try { + resource.containedOrNull("birthDate")?.jsonPrimitive?.asLocalDate() + } catch (expected: DateTimeParseException) { + Napier.e("Could not parse birthdate.", expected) + null + } + + val kvnr = resource + .findAll("identifier") + .filterWith("system", stringValue("http://fhir.de/NamingSystem/gkv/kvid-10")) + .firstOrNull() + ?.containedString("value") + + return processPatient( + name, + resource.extractAddress(processAddress), + birthDate, + kvnr + ) +} + +fun extractPractitioner( + resource: JsonElement, + processPractitioner: PractitionerFn +): Practitioner { + val name = resource.extractHumanName() + + val qualification = resource + .containedArray("qualification") + .find { it.containedOrNull("code")?.containedOrNull("text") != null } + ?.contained("code")?.containedString("text") + + val lanr = resource + .findAll("identifier") + .filterWith("system", stringValue("https://fhir.kbv.de/NamingSystem/KBV_NS_Base_ANR")) + .firstOrNull() + ?.containedString("value") + + return processPractitioner( + name, + qualification, + lanr + ) +} + +fun extractInsuranceInformation( + resource: JsonElement, + processInsuranceInformation: InsuranceInformationFn +): InsuranceInformation { + val name = resource.containedOrNull("payor")?.containedStringOrNull("display") + val statusCode = resource + .findAll("extension") + .filterWith( + "valueCoding.system", + stringValue("https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_VERSICHERTENSTATUS") + ) + .firstOrNull() + ?.contained("valueCoding") + ?.containedString("code") + + return processInsuranceInformation( + name, + statusCode + ) +} + +fun JsonElement.extractAddress(addressFn: AddressFn): R { + val address = this + .containedOrNull("address") + + val line = address + ?.containedArrayOrNull("line") + ?.map { + it.containedString() + } + + val postalCode = address + ?.containedStringOrNull("postalCode") + + val city = address + ?.containedStringOrNull("city") + + return addressFn(line, postalCode, city) +} + +fun JsonElement.extractHumanName(): String? { + return this + .findAll("name") + .filterWith("use", stringValue("official")) + .firstOrNull() + ?.let { name -> + val family = name.containedString("family") + val given = name.containedArray("given").joinToString(" ") { + it.containedString() + } + val prefix = name.containedArrayOrNull("prefix")?.joinToString(" ") { + it.containedString() + } + listOfNotNull(prefix, given, family).joinToString(" ") + } +} + +fun JsonElement.extractMultiplePrescriptionInfo( + processMultiplePrescriptionInfo: MultiplePrescriptionInfoFn, + ratioFn: RatioFn, + quantityFn: QuantityFn +): MultiplePrescriptionInfo { + val indicator = this.findAll("extension").filterWith("url", stringValue("Kennzeichen")) + .first().containedBoolean("valueBoolean") + val numbering = this.findAll("extension").filterWith("url", stringValue("Nummerierung")) + .firstOrNull() + ?.contained("valueRatio") + ?.extractRatio(ratioFn, quantityFn) + val start = this.findAll("extension").filterWith("url", stringValue("Zeitraum")) + .firstOrNull() + ?.contained("valuePeriod") + ?.containedOrNull("start")?.jsonPrimitive?.asLocalDate() + + return processMultiplePrescriptionInfo( + indicator, + numbering, + start + ) +} + +fun extractMedication( + resource: JsonElement, + processMedication: MedicationFn, + ingredientFn: IngredientFn, + ratioFn: RatioFn, + quantityFn: QuantityFn +): Medication { + val text = resource.contained("code").containedStringOrNull("text") + val medicationProfile = when ( + resource.contained("meta").containedArray("profile")[0] + .containedString().split("|").first() + ) { + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN" -> MedicationProfile.PZN + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Compounding" -> MedicationProfile.COMPOUNDING + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Ingredient" -> MedicationProfile.INGREDIENT + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_FreeText" -> MedicationProfile.FREETEXT + else -> error("empty medication profile") + } + val medicationCategoryCode = resource + .findAll("extension") + .filterWith( + "valueCoding.system", + stringValue("https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category") + ) + .first() + .contained("valueCoding") + .containedString("code") + + val medicationCategory = when (medicationCategoryCode) { + "00" -> MedicationCategory.ARZNEI_UND_VERBAND_MITTEL + "01" -> MedicationCategory.BTM + "02" -> MedicationCategory.AMVV + else -> error("unknown medication category") + } + val form = resource.containedOrNull("form") + ?.findAll("coding") + ?.filterWith( + "system", + stringValue( + "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM" + ) + ) + ?.firstOrNull() + ?.containedString("code") ?: resource.containedOrNull("form")?.containedStringOrNull("text") + + val amount = resource.containedOrNull("amount")?.extractRatio(ratioFn, quantityFn) + val vaccine = resource.findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine") + ) + .first() + .containedBoolean("valueBoolean") + val manufacturingInstructions = resource.findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_CompoundingInstruction") + ) + .firstOrNull() + ?.containedString("valueString") + + val packaging = resource.findAll("extension") + .filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Packaging") + ) + .firstOrNull() + ?.containedString("valueString") + + val normSizeCode = resource.findAll("extension") + .filterWith( + "url", + stringValue("http://fhir.de/StructureDefinition/normgroesse") + ) + .firstOrNull() + ?.containedString("valueCode") + + val uniqueIdentifier = + resource.contained("code").findAll("coding") + .filterWith("system", stringValue("http://fhir.de/CodeSystem/ifa/pzn")) + .firstOrNull()?.containedString("code") + + val ingredients = resource.findAll("ingredient").map { + it.extractIngredient(ingredientFn, ratioFn, quantityFn) + }.toList() + + val lotNumber = resource.containedOrNull("batch")?.containedStringOrNull("lotNumber") + val expirationDate = resource.containedOrNull("batch") + ?.containedOrNull("expirationDate")?.jsonPrimitive?.asTemporalAccessor() + + return processMedication( + text, + medicationProfile, + medicationCategory, + form, + amount, + vaccine, + manufacturingInstructions, + packaging, + normSizeCode, + uniqueIdentifier, + ingredients, + lotNumber, + expirationDate + ) +} + +fun JsonElement.extractIngredient( + ingredientFn: IngredientFn, + ratioFn: RatioFn, + quantityFn: QuantityFn +): Ingredient { + val text = this.contained("itemCodeableConcept").containedString("text") + val strength = this.contained("strength") + // FIXME + val amount = strength.findAll("extension").filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Ingredient_Amount") + ).firstOrNull() + ?.containedStringOrNull() + // FIXME + val form = this.findAll("extension").filterWith( + "url", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Ingredient_Form") + ).firstOrNull() + ?.containedStringOrNull() + + val number = this.contained("itemCodeableConcept").containedOrNull("coding")?.containedString("code") + + return ingredientFn( + text, + form, + number, + amount, + strength.extractRatio(ratioFn, quantityFn) + ) +} + +fun JsonElement.extractRatio( + ratioFn: RatioFn, + quantityFn: QuantityFn +): Ratio { + val numerator = this.containedOrNull("numerator") + val denominator = this.containedOrNull("denominator") + + return ratioFn( + numerator?.extractQuantity(quantityFn), + denominator?.extractQuantity(quantityFn) + ) +} + +fun JsonElement.extractQuantity(quantityFn: QuantityFn): Quantity { + val value = this.containedStringOrNull("value") ?: "" + val unit = this.containedStringOrNull("unit") ?: "" + + return quantityFn(value, unit) +} + +private fun JsonElement.isProfileValueOfMedication(vararg versions: String): Boolean { + return isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN", + *versions + ) || isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Compounding", + *versions + ) || isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Ingredient", + *versions + ) || isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_FreeText", + *versions + ) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt new file mode 100644 index 00000000..461e74cc --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapper.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asLocalDate +import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedArray +import de.gematik.ti.erp.app.fhir.parser.containedBooleanOrNull +import de.gematik.ti.erp.app.fhir.parser.containedOrNull +import de.gematik.ti.erp.app.fhir.parser.containedString +import de.gematik.ti.erp.app.fhir.parser.containedStringOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.time.LocalDate + +typealias MedicationDispenseFn = ( + dispenseId: String, + patientIdentifier: String, // KVNR + medication: Medication?, + wasSubstituted: Boolean, + dosageInstruction: String?, + performer: String, // Telematik-ID + whenHandedOver: LocalDate +) -> R + +fun extractMedicationDispense( + resource: JsonElement, + processMedicationDispense: MedicationDispenseFn, + processMedication: MedicationFn, + ingredientFn: IngredientFn, + ratioFn: RatioFn, + quantityFn: QuantityFn +): MedicationDispense { + val dispenseId = resource.containedString("id") + val patientIdentifier = resource.contained("subject").contained("identifier").containedString("value") + val medication = extractMedication( + resource.containedArray("contained")[0], + processMedication, + ingredientFn, + ratioFn, + quantityFn + ) + val wasSubstituted = resource.containedOrNull("substitution") + ?.containedBooleanOrNull("wasSubstituted") ?: false + val dosageInstruction = resource.containedOrNull("dosageInstruction")?.containedStringOrNull("text") + val performer = resource.containedArray("performer")[0] + .contained("actor").contained("identifier").containedString("value") // Telematik-ID + val whenHandedOver = resource.contained("whenHandedOver").jsonPrimitive.asLocalDate() + ?: error("error on parsing date of delivery") + + return processMedicationDispense( + dispenseId, + patientIdentifier, + medication, + wasSubstituted, + dosageInstruction, + performer, + whenHandedOver + ) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt new file mode 100644 index 00000000..d765e3f5 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.containedArray +import de.gematik.ti.erp.app.fhir.parser.containedArrayOrNull +import de.gematik.ti.erp.app.fhir.parser.containedDouble +import de.gematik.ti.erp.app.fhir.parser.containedInt +import de.gematik.ti.erp.app.fhir.parser.containedObject +import de.gematik.ti.erp.app.fhir.parser.containedString +import de.gematik.ti.erp.app.fhir.parser.containedStringOrNull +import de.gematik.ti.erp.app.fhir.parser.filterWith +import de.gematik.ti.erp.app.fhir.parser.findAll +import de.gematik.ti.erp.app.fhir.parser.not +import de.gematik.ti.erp.app.fhir.parser.or +import de.gematik.ti.erp.app.fhir.parser.stringValue +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.JsonElement +import java.net.MalformedURLException +import java.net.URL +import java.time.DayOfWeek +import java.time.LocalTime + +val Contained = listOf("contained") +val TypeCodingCode = listOf("type", "coding", "code") + +/** + * Extract pharmacy services from a search bundle. + */ +fun extractPharmacyServices( + bundle: JsonElement, + onError: (JsonElement, Exception) -> Unit = { _, _ -> } +): PharmacyServices { + val bundleId = bundle.containedString("id") + val bundleTotal = bundle.containedInt("total") + val resources = bundle.findAll(listOf("entry", "resource")).filterWith("name", not(stringValue("-"))) + + val pharmacies = resources.mapCatching(onError) { resource -> + val locationName = resource.containedString("name") + val localService = LocalPharmacyService( + name = locationName, + openingHours = resource.containedArrayOrNull("hoursOfOperation")?.let { hoursOfOperation(it) } + ?: OpeningHours(emptyMap()) + ) + + val deliveryPharmacyService = + resource + .findAll(Contained) + .filterWith(TypeCodingCode, stringValue("498")) + .firstOrNull() + ?.let { service -> + DeliveryPharmacyService( + name = locationName, + openingHours = service.containedArrayOrNull("availableTime")?.let { availableTime(it) } + ?: OpeningHours(emptyMap()) + ) + } + + // keep it; was initially part of the spec but no pharmacy can provide any emergency service + // + // val emergencyPharmacyService = resource + // .findAll("contained") + // .filterWith("type.coding.code", stringValue("117")) + // .firstOrNull() + // ?.let { + // EmergencyPharmacyService( + // name = locationName, + // openingHours = availableTime(it.containedArray("availableTime")!!) + // ) + // } + + val telematikId = + resource + .findAll(listOf("identifier")) + .filterWith( + listOf("system"), + or( + stringValue("https://gematik.de/fhir/NamingSystem/TelematikID"), + stringValue("https://gematik.de/fhir/sid/telematik-id") + ) + ) + .first() + .containedString("value") + + var isOutpatientPharmacy = false + var isMobilePharmacy = false + + resource.findAll(TypeCodingCode).forEach { + when (it.containedString()) { + "OUTPHARM" -> isOutpatientPharmacy = true + "MOBL" -> isMobilePharmacy = true + } + } + + val pickUpPharmacyService = if (isOutpatientPharmacy) { + PickUpPharmacyService(name = locationName) + } else { + null + } + + val onlinePharmacyService = if (isMobilePharmacy) { + OnlinePharmacyService(name = locationName) + } else { + null + } + + val position = resource.containedObject("position").let { + Location( + latitude = it.containedDouble("latitude"), + longitude = it.containedDouble("longitude") + ) + } + + Pharmacy( + name = locationName, + location = position, + address = resource.containedObject("address").let { address -> + PharmacyAddress( + lines = address.containedArray("line").map { it.containedString() }, + postalCode = address.containedString("postalCode"), + city = address.containedString("city") + ) + }, + contacts = resource.containedArrayOrNull("telecom")?.let { contacts(it) } ?: PharmacyContacts( + "", + "", + "" + ), + provides = listOfNotNull( + localService, + deliveryPharmacyService, + onlinePharmacyService, + pickUpPharmacyService + ), + telematikId = telematikId, + ready = resource.containedString("status") == "active" + ) + } + + return PharmacyServices( + pharmacies = pharmacies.toList(), + bundleId = bundleId, + bundleResultCount = bundleTotal + ) +} + +private fun Sequence.mapCatching( + onError: (JsonElement, Exception) -> Unit, + transform: (JsonElement) -> R? +): Sequence = + mapNotNull { + try { + transform(it) + } catch (e: Exception) { + onError(it, e) + null + } + } + +private fun sanitizeUrl(url: String): String = + try { + require(url.startsWith("http")) + + URL(url).toString() + } catch (_: MalformedURLException) { + "" + } catch (_: IllegalArgumentException) { + "" + } + +private fun contacts( + telecom: JsonArray +): PharmacyContacts { + var phone = "" + var mail = "" + var url = "" + + telecom + .forEach { + when (it.containedString("system")) { + "phone" -> phone = it.containedStringOrNull("value") ?: "" + "email" -> mail = it.containedStringOrNull("value") ?: "" + "url" -> url = sanitizeUrl(it.containedStringOrNull("value") ?: "") + } + } + + return PharmacyContacts( + phone = phone, + mail = mail, + url = url + ) +} + +private fun availableTime( + hoursOfOperation: JsonArray +): OpeningHours = + openingHours( + hoursOfOperation = hoursOfOperation, + startTimeAlias = "availableStartTime", + endTimeAlias = "availableEndTime" + ) + +private fun hoursOfOperation( + hoursOfOperation: JsonArray +): OpeningHours = + openingHours( + hoursOfOperation = hoursOfOperation, + startTimeAlias = "openingTime", + endTimeAlias = "closingTime" + ) + +private fun openingHours( + hoursOfOperation: JsonArray, + startTimeAlias: String, + endTimeAlias: String +): OpeningHours = + hoursOfOperation + .asSequence() + .flatMap { fhirHours -> + (fhirHours as JsonObject).let { + val openingTime = lookupTime(fhirHours[startTimeAlias]?.jsonPrimitive) + ?: LocalTime.MIN + + val closingTime = lookupTime(fhirHours[endTimeAlias]?.jsonPrimitive) + ?: LocalTime.MAX + + val time = OpeningTime(openingTime = openingTime, closingTime = closingTime) + + fhirHours.containedArray("daysOfWeek") + .asSequence() + .map { fhirDay(it.containedString()) to time } + } + } + .groupBy({ (day, _) -> day }, { (_, time) -> time }) + .let { + OpeningHours(it) + } + +private fun fhirDay(day: String) = + when (day) { + "mon" -> DayOfWeek.MONDAY + "tue" -> DayOfWeek.TUESDAY + "wed" -> DayOfWeek.WEDNESDAY + "thu" -> DayOfWeek.THURSDAY + "fri" -> DayOfWeek.FRIDAY + "sat" -> DayOfWeek.SATURDAY + "sun" -> DayOfWeek.SUNDAY + else -> error("wrong day format: $day") + } + +private fun openingHours(days: List, openingTime: LocalTime, closingTime: LocalTime) = + days.map { + it to OpeningTime(openingTime = openingTime, closingTime = closingTime) + } diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacySearchModel.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacySearchModel.kt similarity index 73% rename from android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacySearchModel.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacySearchModel.kt index 7508758d..766da4d9 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/PharmacySearchModel.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacySearchModel.kt @@ -16,55 +16,52 @@ * */ -package de.gematik.ti.erp.app.pharmacy.repository.model +package de.gematik.ti.erp.app.fhir.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize import java.time.DayOfWeek import java.time.LocalTime import java.time.OffsetDateTime +import kotlin.math.PI import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt -private const val EPSILON = 1e-6 +private const val EqEpsilon = 1e-6 +private const val EarthRadiusInMeter = 6371e3 -enum class RoleCode { - OUT_PHARM, MOBL, PHARM -} - -data class PharmacySearchResult( +data class PharmacyServices( val pharmacies: List, val bundleId: String, val bundleResultCount: Int ) -@Parcelize data class Location( val latitude: Double, val longitude: Double -) : Parcelable { - +) { + /** + * Haversine distance between two points on a sphere. + */ fun distanceInMeters(other: Location): Double { - val distance = FloatArray(1) - android.location.Location.distanceBetween( - this.latitude, - this.longitude, - other.latitude, - other.longitude, - distance - ) - return distance[0].toDouble() + val dLat = toRadians(other.latitude - this.latitude) + val dLon = toRadians(other.longitude - this.longitude) + val lat1 = toRadians(this.latitude) + val lat2 = toRadians(other.latitude) + val a = sin(dLat / 2).pow(2) + sin(dLon / 2).pow(2) * cos(lat1) * cos(lat2) + val c = 2 * asin(sqrt(a)) + return EarthRadiusInMeter * c } - /** - * @see distanceInMeters - */ + private fun toRadians(deg: Double) = deg / 180.0 * PI operator fun minus(other: Location) = distanceInMeters(other) - override fun equals(other: Any?): Boolean = if (other == null || other !is Location) { false } else { - abs(this.latitude - other.latitude) < EPSILON && abs(this.longitude - other.longitude) < EPSILON + abs(this.latitude - other.latitude) < EqEpsilon && abs(this.longitude - other.longitude) < EqEpsilon } override fun hashCode(): Int { @@ -77,15 +74,14 @@ data class Location( data class PharmacyAddress( val lines: List, val postalCode: String, - val city: String, + val city: String ) -@Parcelize data class PharmacyContacts( val phone: String, val mail: String, - val url: String, -) : Parcelable + val url: String +) data class Pharmacy( val name: String, @@ -94,17 +90,15 @@ data class Pharmacy( val contacts: PharmacyContacts, val provides: List, val telematikId: String, - val roleCode: List, val ready: Boolean ) -interface PharmacyService : Parcelable { - val openingHours: OpeningHours +sealed interface PharmacyService +interface TemporalPharmacyService : PharmacyService { + val openingHours: OpeningHours fun isOpenAt(tm: OffsetDateTime) = openingHours.isOpenAt(tm) - fun isAllDayOpen(day: DayOfWeek) = openingHours[day]?.any { it.isAllDayOpen() } ?: false - fun openUntil(tm: OffsetDateTime): LocalTime? { val localTm = tm.toLocalTime() return openingHours[tm.dayOfWeek]?.find { @@ -120,38 +114,36 @@ interface PharmacyService : Parcelable { } } -// data class OnlinePharmacyService( -// val name: String, override val openingHours: List -// ) : PharmacyService +data class OnlinePharmacyService( + val name: String +) : PharmacyService + +data class PickUpPharmacyService( + val name: String +) : PharmacyService -@Parcelize data class DeliveryPharmacyService( val name: String, override val openingHours: OpeningHours -) : PharmacyService +) : TemporalPharmacyService -@Parcelize data class EmergencyPharmacyService( val name: String, override val openingHours: OpeningHours -) : PharmacyService +) : TemporalPharmacyService -@Parcelize data class LocalPharmacyService( val name: String, override val openingHours: OpeningHours -) : PharmacyService +) : TemporalPharmacyService -@Parcelize data class OpeningHours(val openingTime: Map>) : - Parcelable, Map> by openingTime -@Parcelize data class OpeningTime( val openingTime: LocalTime, val closingTime: LocalTime -) : Parcelable { +) { fun isOpenAt(tm: LocalTime) = tm in openingTime..closingTime fun isAllDayOpen() = openingTime == LocalTime.MIN && closingTime == LocalTime.MAX } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/TaskMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/TaskMapper.kt new file mode 100644 index 00000000..f605aeac --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/TaskMapper.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.asInstant +import de.gematik.ti.erp.app.fhir.parser.asLocalDate +import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedArrayOrNull +import de.gematik.ti.erp.app.fhir.parser.containedString +import de.gematik.ti.erp.app.fhir.parser.filterWith +import de.gematik.ti.erp.app.fhir.parser.findAll +import de.gematik.ti.erp.app.fhir.parser.isProfileValue +import de.gematik.ti.erp.app.fhir.parser.profileValue +import de.gematik.ti.erp.app.fhir.parser.stringValue +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.time.Instant +import java.time.LocalDate + +enum class TaskStatus { + Ready, + InProgress, + Completed, + Other, + Draft, + Requested, + Received, + Accepted, + Rejected, + Canceled, + OnHold, + Failed; +} + +fun extractTaskIds( + bundle: JsonElement +): Pair> { + val bundleTotal = bundle.containedArrayOrNull("entry")?.size ?: 0 + val resources = bundle + .findAll("entry.resource") + + val taskIds = resources.mapNotNull { resource -> + val profileString = resource + .contained("meta") + .contained("profile") + .contained() + + if ( + profileValue( + "https://gematik.de/fhir/StructureDefinition/ErxTask", + "1.1.1" + ).invoke(profileString) + ) { + resource + .findAll("identifier") + .filterWith("system", stringValue("https://gematik.de/fhir/NamingSystem/PrescriptionID")) + .first() + .containedString("value") + } else { + null + } + } + + return bundleTotal to taskIds.toList() +} + +fun extractTaskAndKBVBundle( + bundle: JsonElement, + process: ( + taskResource: JsonElement, + bundleResource: JsonElement + ) -> Unit +) { + val resources = bundle + .findAll("entry.resource") + + lateinit var task: JsonElement + lateinit var kbvBundle: JsonElement + + resources.forEach { resource -> + val profileString = resource + .contained("meta") + .contained("profile") + .contained() + + when { + profileString.isProfileValue( + "https://gematik.de/fhir/StructureDefinition/ErxTask", + "1.1.1" + ) -> { + task = resource + } + profileString.isProfileValue( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle", + "1.0.2" + ) -> { + kbvBundle = resource + } + } + } + + process(task, kbvBundle) +} + +fun extractTask( + task: JsonElement, + process: ( + taskId: String, + accessCode: String?, + lastModified: Instant, + expiresOn: LocalDate?, + acceptUntil: LocalDate?, + authoredOn: Instant, + status: TaskStatus + ) -> Unit +) { + val taskId = task + .findAll("identifier") + .filterWith("system", stringValue("https://gematik.de/fhir/NamingSystem/PrescriptionID")) + .first() + .containedString("value") + + val accessCode = task + .findAll("identifier") + .filterWith("system", stringValue("https://gematik.de/fhir/NamingSystem/AccessCode")) + .firstOrNull() + ?.containedString("value") + + val status = when (task.containedString("status")) { + "ready" -> TaskStatus.Ready + "in-progress" -> TaskStatus.InProgress + "completed" -> TaskStatus.Completed + "canceled" -> TaskStatus.Canceled + "accepted" -> TaskStatus.Accepted + "draft" -> TaskStatus.Draft + "failed" -> TaskStatus.Failed + "on-hold" -> TaskStatus.OnHold + "requested" -> TaskStatus.Requested + "received" -> TaskStatus.Received + "rejected" -> TaskStatus.Rejected + else -> TaskStatus.Other + } + + val authoredOn = requireNotNull(task.contained("authoredOn").jsonPrimitive.asInstant()) { + "Couldn't parse `authoredOn`" + } + val lastModified = requireNotNull(task.contained("lastModified").jsonPrimitive.asInstant()) { + "Couldn't parse `lastModified`" + } + + val expiresOn = task + .findAll("extension") + .filterWith("url", stringValue("https://gematik.de/fhir/StructureDefinition/ExpiryDate")) + .first() + .contained("valueDate") + .jsonPrimitive.asLocalDate() + + val acceptUntil = task + .findAll("extension") + .filterWith("url", stringValue("https://gematik.de/fhir/StructureDefinition/AcceptDate")) + .first() + .contained("valueDate") + .jsonPrimitive.asLocalDate() + + process( + taskId, + accessCode, + lastModified, + expiresOn, + acceptUntil, + authoredOn, + status + ) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Comperator.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Comperator.kt new file mode 100644 index 00000000..4699d3ba --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Comperator.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +typealias JsonComparator = (value: JsonElement) -> Boolean + +internal class OrComparator(private val comparators: List) : JsonComparator { + override fun invoke(value: JsonElement): Boolean = + comparators.any { + it(value) + } + + override fun toString(): String { + return "OrComparator(${comparators.joinToString()})" + } +} + +fun or(vararg comparator: JsonComparator): JsonComparator = + OrComparator(comparator.toList()) + +internal class NotComparator(private val comparator: JsonComparator) : JsonComparator { + override fun invoke(value: JsonElement): Boolean = + !comparator(value) + + override fun toString(): String { + return "NotComparator($comparator)" + } +} + +fun not(comparator: JsonComparator): JsonComparator = + NotComparator(comparator) + +internal class RegexJsonComparator(private val regex: Regex) : JsonComparator { + override fun invoke(value: JsonElement): Boolean = + if (value is JsonPrimitive) { + value.contentOrNull?.matches(regex) ?: false + } else { + false + } + + override fun toString(): String { + return "RegexJsonComparator($regex)" + } +} + +fun regexValue(regex: Regex): JsonComparator = + RegexJsonComparator(regex) + +internal class StringJsonComparator(private val otherValue: String, private val ignoreCase: Boolean) : JsonComparator { + override fun invoke(value: JsonElement): Boolean = + value is JsonPrimitive && value.contentOrNull.equals(otherValue, ignoreCase) + + override fun toString(): String { + return "StringJsonComparator($otherValue)" + } +} + +fun stringValue(value: String, ignoreCase: Boolean = false): JsonComparator = + StringJsonComparator(value, ignoreCase) + +internal class RangeJsonComparator>( + private val range: ClosedRange, + private val converter: (String) -> T? +) : JsonComparator { + override fun invoke(value: JsonElement): Boolean = + (value as? JsonPrimitive) + ?.contentOrNull + ?.let { converter(it) } + ?.let { it in range } + ?: false + + override fun toString(): String { + return "RangeJsonComparator($range)" + } +} + +public fun > rangeValue(range: ClosedRange, converter: (String) -> T?): JsonComparator = + RangeJsonComparator(range, converter) + +internal class ProfileStringComparator( + private val base: String, + private val versions: Array +) : JsonComparator { + override fun invoke(value: JsonElement): Boolean = + (value as? JsonPrimitive) + ?.contentOrNull + ?.let { + val path = it.split('|', limit = 2) + when { + path.size == 2 && versions.isNotEmpty() -> { + val matchesBasePath = path[0] == base + val matchesVersion = versions.any { v -> path[1] == v } + matchesBasePath && matchesVersion + } + path.size == 1 && versions.isEmpty() -> { + path[0] == base + } + else -> false + } + } + ?: false + + override fun toString(): String { + return "ProfileStringComparator($base|(${versions.joinToString("|")}))" + } +} + +fun profileValue(base: String, vararg versions: String): JsonComparator = + ProfileStringComparator(base, versions) + +fun JsonElement.isProfileValue(base: String, vararg versions: String) = + profileValue(base, *versions).invoke(this) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Converter.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Converter.kt new file mode 100644 index 00000000..70b5d4e3 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Converter.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +@file:Suppress("TooManyFunctions") + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.double +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.Year +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAccessor + +/** + * The Fhir documentation mentions the following formats: + * + * instant YYYY-MM-DDThh:mm:ss.sss+zz:zz + * datetime YYYY, YYYY-MM, YYYY-MM-DD or YYYY-MM-DDThh:mm:ss+zz:zz + * date YYYY, YYYY-MM, or YYYY-MM-DD + * time hh:mm:ss + * + */ + +private const val DtFormatterPattern = "[HH:mm:ss][yyyy[-MM[-dd]]['T'HH:mm:ss[.SSS]XXX]]" +private const val DtFormatterPatternTime = "HH:mm:ss" + +private val Formatter = DateTimeFormatter.ofPattern(DtFormatterPattern) +private val FormatterTime = DateTimeFormatter.ofPattern(DtFormatterPatternTime) + +fun String?.asTemporalAccessor(): TemporalAccessor? = + this?.let { + Formatter + .parseBest( + it, + Instant::from, + LocalDate::from, + YearMonth::from, + Year::from, + LocalTime::from + ) + } + +fun TemporalAccessor?.asFormattedString(): String? = + this?.let { Formatter.format(it) } + +fun JsonPrimitive.asTemporalAccessor(): TemporalAccessor? = + this.contentOrNull?.let { + Formatter + .parseBest( + it, + Instant::from, + LocalDate::from, + YearMonth::from, + Year::from, + LocalTime::from + ) + } + +fun JsonPrimitive.asLocalTime(): LocalTime? = + this.contentOrNull?.let { + FormatterTime.parse(it, LocalTime::from) + } + +fun JsonPrimitive.asLocalDateTime(): LocalDateTime? = + this.contentOrNull?.let { + Formatter.parse(it, LocalDateTime::from) + } + +fun JsonPrimitive.asLocalDate(): LocalDate? = + this.contentOrNull?.let { + Formatter.parse(it, LocalDate::from) + } + +fun JsonPrimitive.asInstant(): Instant? = + this.contentOrNull?.let { + Formatter.parse(it, Instant::from) + } + +/** + * Returns the first element in the JSON structure. For arrays this is the first element. + * + * With [this] being the element of `foo`, + * `{ "foo": "bar" }` and `{ "foo": [ "bar" ] }` + * return both the [JsonPrimitive] with its content `bar`. + */ +fun JsonElement.contained() = + when (this) { + is JsonArray -> this.first() + else -> this + } + +fun JsonElement.containedOrNull() = + when (this) { + is JsonArray -> this.firstOrNull() + else -> this + } + +fun JsonElement.containedObject() = + this.contained().jsonObject + +fun JsonElement.containedObjectOrNull() = + this.containedOrNull() as? JsonObject + +/** + * Returns the first contained array or otherwise [this] if the contained type is not an array. + * If [this] is not an array as well, `null` is returned. + * + * With [this] being the element of `foo`, + * `{ "foo": [ [ { "bar": "baz" } ] ] }` and `{ "foo": [ { "bar": "baz" } ] }` + * return both the [JsonArray] with its content `[ { "bar": "baz" } ]`. + */ +fun JsonElement.containedArray() = + this.contained() as? JsonArray ?: this.jsonArray + +fun JsonElement.containedArrayOrNull() = + this.containedOrNull() as? JsonArray ?: this as? JsonArray + +fun JsonElement.containedString() = + this.contained().jsonPrimitive.content + +fun JsonElement.containedStringOrNull() = + (this.containedOrNull() as? JsonPrimitive)?.contentOrNull + +fun JsonElement.containedBoolean() = + this.contained().jsonPrimitive.boolean + +fun JsonElement.containedBooleanOrNull() = + (this.containedOrNull() as? JsonPrimitive)?.booleanOrNull + +fun JsonElement.containedInt() = + this.contained().jsonPrimitive.int + +fun JsonElement.containedIntOrNull() = + (this.containedOrNull() as? JsonPrimitive)?.intOrNull + +fun JsonElement.containedDouble() = + this.contained().jsonPrimitive.double + +fun JsonElement.containedDoubleOrNull() = + (this.containedOrNull() as? JsonPrimitive)?.doubleOrNull + +/** + * Will return the first element in the JSON structure. + * + * With [this] being the element of `foo` and [key] is `bar`, + * `{ "foo": { "bar": "baz" } }` and `{ "foo": [ { "bar": "baz" } ] }` + * return both the [JsonPrimitive] with its content `baz`. + */ +fun JsonElement.contained(key: String) = + when (this) { + is JsonObject -> this[key] ?: error("`$key` not found") + is JsonArray -> this.first().jsonObject[key] ?: error("`$key` not found") + else -> error("`this` needs to be JsonObject or JsonArray") + } + +fun JsonElement.containedOrNull(key: String) = + when (this) { + is JsonObject -> this[key] + is JsonArray -> (this.firstOrNull() as? JsonObject)?.get(key) + else -> null + } + +fun JsonElement.containedObject(key: String) = + this.contained(key).containedObject() + +fun JsonElement.containedArray(key: String) = + this.contained(key).containedArray() + +fun JsonElement.containedArrayOrNull(key: String) = + this.containedOrNull(key)?.containedArrayOrNull() + +fun JsonElement.containedString(key: String) = + this.contained(key).containedString() + +fun JsonElement.containedStringOrNull(key: String) = + this.containedOrNull(key)?.containedStringOrNull() + +fun JsonElement.containedBoolean(key: String) = + this.contained(key).containedBoolean() + +fun JsonElement.containedBooleanOrNull(key: String) = + this.containedOrNull(key)?.containedBooleanOrNull() + +fun JsonElement.containedInt(key: String) = + this.contained(key).containedInt() + +fun JsonElement.containedIntOrNull(key: String) = + this.containedOrNull(key)?.containedIntOrNull() + +fun JsonElement.containedDouble(key: String) = + this.contained(key).containedDouble() + +fun JsonElement.containedDoubleOrNull(key: String) = + this.containedOrNull(key)?.containedDoubleOrNull() diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Formatter.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Formatter.kt new file mode 100644 index 00000000..852da16f --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Formatter.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonArrayBuilder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.addJsonArray +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject + +/** + * Returns the original JSON without the values set to [transform]. + */ +internal fun JsonElement.transformValues(transform: (JsonPrimitive) -> JsonPrimitive): JsonElement = + when (val element = this) { + is JsonObject -> { + buildJsonObject { + element.entries.forEach { + walkTree(it.key, it.value, transform) + } + } + } + is JsonArray -> { + buildJsonArray { + element.forEach { + walkTree(it, transform) + } + } + } + else -> JsonNull + } + +private fun JsonObjectBuilder.walkTree(key: String, element: JsonElement, transform: (JsonPrimitive) -> JsonPrimitive) { + when (element) { + is JsonObject -> + putJsonObject(key) { + element.entries.forEach { + walkTree(it.key, it.value, transform) + } + } + is JsonArray -> + putJsonArray(key) { + element.forEach { + walkTree(it, transform) + } + } + is JsonPrimitive -> + put(key, transform(element)) + else -> error("Unknown element $element at $key") + } +} + +private fun JsonArrayBuilder.walkTree(element: JsonElement, transform: (JsonPrimitive) -> JsonPrimitive) { + when (element) { + is JsonObject -> + addJsonObject { + element.entries.forEach { + walkTree(it.key, it.value, transform) + } + } + is JsonArray -> + addJsonArray { + element.forEach { + walkTree(it, transform) + } + } + is JsonPrimitive -> + add(transform(element)) + else -> error("Unknown element $element") + } +} + +object JsonPrimitiveAsNullSerializer : JsonTransformingSerializer(JsonElement.serializer()) { + override fun transformSerialize(element: JsonElement): JsonElement = + element.transformValues(transform = { JsonNull }) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Parser.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Parser.kt new file mode 100644 index 00000000..8156fafd --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Parser.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +private const val PathDelimiter = '.' + +internal fun JsonElement.walk(path: List): Iterator = + iterator { + when (this@walk) { + is JsonObject -> walk(this@walk, path) + is JsonArray -> walk(this@walk, path) + else -> {} + } + } + +internal suspend fun SequenceScope.walk(obj: JsonObject, path: List) { + val prefix = if (path.isNotEmpty()) path.first() else "" + val suffix = if (path.isNotEmpty()) path.subList(1, path.size) else emptyList() + + if (prefix.isEmpty()) { + // we are at the right element + yield(obj) + } else { + when (val v = obj[prefix]) { + is JsonObject -> walk(v, suffix) + is JsonArray -> walk(v, suffix) + is JsonPrimitive -> + if (suffix.isEmpty()) { + // prefix matches primitive value and remaining path is empty + yield(v) + } + else -> {} + } + } +} + +internal suspend fun SequenceScope.walk(arr: JsonArray, path: List) { + arr.forEach { + when (it) { + is JsonObject -> walk(it, path) + is JsonPrimitive -> yield(it) + else -> {} + } + } +} + +fun JsonElement.findAll(base: List): Sequence = + walk(base) + .asSequence() + +fun JsonElement.findAll(base: String): Sequence = + findAll(splitPath(base)) + +fun Sequence.findAll(base: List): Sequence { + return asSequence().flatMap { + it.findAll(base) + } +} + +fun Sequence.findAll(base: String): Sequence { + val splitBase = splitPath(base) + return asSequence().flatMap { + it.findAll(splitBase) + } +} + +fun Sequence.filterWith(relative: List, matches: JsonComparator): Sequence = + filter { + it.findAll(relative).any { el -> + matches(el) + } + } + +fun Sequence.filterWith(relative: String, matches: JsonComparator): Sequence = + filterWith(splitPath(relative), matches) + +private fun splitPath(path: String): List { + require(!path.startsWith('.')) { "A path can't start with a dot." } + require(!path.endsWith('.')) { "A path can't end with a dot." } + return path.split(PathDelimiter) +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/AlgorithmIdentifiersExtending.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/AlgorithmIdentifiersExtending.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/idp/AlgorithmIdentifiersExtending.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/AlgorithmIdentifiersExtending.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/EcdsaUsingShaAlgorithmExtending.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/EcdsaUsingShaAlgorithmExtending.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/idp/EcdsaUsingShaAlgorithmExtending.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/EcdsaUsingShaAlgorithmExtending.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/EllipticCurvesExtending.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/EllipticCurvesExtending.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/idp/EllipticCurvesExtending.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/EllipticCurvesExtending.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/JWTExtensions.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/JWTExtensions.kt similarity index 89% rename from android/src/main/java/de/gematik/ti/erp/app/idp/JWTExtensions.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/JWTExtensions.kt index 65cee690..da7fac4b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/JWTExtensions.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/JWTExtensions.kt @@ -19,12 +19,12 @@ package de.gematik.ti.erp.app.idp import org.jose4j.base64url.Base64Url +import org.jose4j.json.internal.json_simple.JSONObject import org.jose4j.jwe.ContentEncryptionAlgorithmIdentifiers import org.jose4j.jwe.JsonWebEncryption import org.jose4j.jwe.KeyManagementAlgorithmIdentifiers import org.jose4j.jws.EcdsaUsingShaAlgorithm import org.jose4j.jws.JsonWebSignature -import org.json.JSONObject import java.security.MessageDigest import java.security.PrivateKey import java.security.PublicKey @@ -55,7 +55,10 @@ private class JsonWebSignatureWithHealthCard : JsonWebSignature() { "$encodedHeader.$encodedPayload" } -suspend fun buildJsonWebSignatureWithHealthCard(builder: JsonWebSignature.() -> Unit, sign: suspend (hash: ByteArray) -> ByteArray): String { +suspend fun buildJsonWebSignatureWithHealthCard( + builder: JsonWebSignature.() -> Unit, + sign: suspend (hash: ByteArray) -> ByteArray +): String { val jwsWithHealthCard = JsonWebSignatureWithHealthCard() builder(jwsWithHealthCard) @@ -72,7 +75,11 @@ suspend fun buildJsonWebSignatureWithHealthCard(builder: JsonWebSignature.() -> return "$headerAndPayload.${Base64Url().base64UrlEncode(signed)}" } -fun buildJsonWebSignatureWithSecureElement(builder: JsonWebSignature.() -> Unit, privateKey: PrivateKey, signature: Signature): String { +fun buildJsonWebSignatureWithSecureElement( + builder: JsonWebSignature.() -> Unit, + privateKey: PrivateKey, + signature: Signature +): String { val jwsWithHealthCard = JsonWebSignatureWithHealthCard() builder(jwsWithHealthCard) diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/api/IdpService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt similarity index 65% rename from android/src/main/java/de/gematik/ti/erp/app/idp/api/IdpService.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt index b09af5c1..486b62b4 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/api/IdpService.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt @@ -18,15 +18,17 @@ package de.gematik.ti.erp.app.idp.api -import android.net.Uri import de.gematik.ti.erp.app.idp.api.models.Challenge import de.gematik.ti.erp.app.idp.api.models.JWSPublicKey +import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntries import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntry import de.gematik.ti.erp.app.idp.api.models.TokenResponse import de.gematik.ti.erp.app.idp.repository.JWSDiscoveryDocument +import java.net.URI import okhttp3.ResponseBody import org.jose4j.jws.JsonWebSignature import retrofit2.Response +import retrofit2.http.DELETE import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET @@ -36,14 +38,14 @@ import retrofit2.http.POST import retrofit2.http.Query import retrofit2.http.Url -const val REDIRECT_URI = "https://redirect.gematik.de/erezept" const val CLIENT_ID = "eRezeptApp" +const val REDIRECT_URI = "https://redirect.gematik.de/erezept" const val EXT_AUTH_REDIRECT_URI: String = "https://das-e-rezept-fuer-deutschland.de/extauth" interface IdpService { @Headers( - "Accept: application/jwt;charset=UTF-8", + "Accept: application/jwt;charset=UTF-8" ) @GET("openid-configuration") suspend fun discoveryDocument(): Response @@ -66,15 +68,15 @@ interface IdpService { @GET suspend fun requestAuthenticationRedirect( @Url url: String, - @Query("kk_app_id")externalAppId: String, - @Query("nonce")nonce: String, - @Query("state")state: String, - @Query("client_id")clientID: String = "eRezeptApp", - @Query("redirect_uri")redirectUri: String = EXT_AUTH_REDIRECT_URI, - @Query("code_challenge_method")codeChallengeMethod: String = "S256", - @Query("response_type")responseType: String = "code", - @Query("scope")scope: String = "e-rezept openid", - @Query("code_challenge")codeChallenge: String + @Query("kk_app_id") externalAppId: String, + @Query("nonce") nonce: String, + @Query("state") state: String, + @Query("client_id") clientID: String = CLIENT_ID, + @Query("redirect_uri") redirectUri: String = EXT_AUTH_REDIRECT_URI, + @Query("code_challenge_method") codeChallengeMethod: String = "S256", + @Query("response_type") responseType: String = "code", + @Query("scope") scope: String, + @Query("code_challenge") codeChallenge: String ): Response @GET @@ -82,7 +84,7 @@ interface IdpService { @Url url: String, @Query("client_id") clientId: String = CLIENT_ID, @Query("response_type") responseType: String = "code", - @Query("redirect_uri") redirect_uri: String = REDIRECT_URI, + @Query("redirect_uri") redirectUri: String, @Query("state") state: String, @Query("code_challenge") codeChallenge: String, @Query("code_challenge_method") codeChallengeMethod: String = "S256", @@ -93,7 +95,7 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun authorization( @Url url: String, @@ -103,12 +105,12 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun token( @Url url: String, @Field("grant_type") grantType: String = "authorization_code", - @Field("redirect_uri") redirectUri: String = REDIRECT_URI, + @Field("redirect_uri") redirectUri: String, @Field("client_id") clientId: String = CLIENT_ID, @Field("key_verifier") keyVerifier: String, @Field("code") code: String @@ -117,12 +119,12 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun ssoToken( @Url url: String, @Field("ssotoken") ssoToken: String, - @Field("unsigned_challenge") unsignedChallenge: String, + @Field("unsigned_challenge") unsignedChallenge: String ): Response /** @@ -135,25 +137,49 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) - suspend fun pairing( + suspend fun postPairing( @Url url: String, @Header("Authorization") bearerToken: String, - @Field("encrypted_registration_data") data: String, + @Field("encrypted_registration_data") data: String ): Response + /** + * Registration `gemF_Biometrie 4.1.3.3` + */ + @GET + @Headers( + "Accept: application/json" + ) + suspend fun getPairing( + @Url url: String, + @Header("Authorization") bearerToken: String + ): Response + + /** + * Registration `gemF_Biometrie 4.1.3.3` + */ + @DELETE + @Headers( + "Accept: application/json" + ) + suspend fun deletePairing( + @Url url: String, + @Header("Authorization") bearerToken: String + ): Response + /** * Authentication `gemF_Biometrie 4.1.3.2` */ @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun authenticate( @Url url: String, - @Field("encrypted_signed_authentication_data") data: String, + @Field("encrypted_signed_authentication_data") data: String ): Response /** @@ -163,15 +189,20 @@ interface IdpService { @POST suspend fun externalAuthorization( @Url url: String, - @Field("code")code: String, - @Field("state")state: String, - @Field("kk_app_redirect_uri")kk_app_redirect_uri: String + @Field("code") code: String, + @Field("state") state: String, + @Field("kk_app_redirect_uri") redirectUri: String ): Response companion object { - fun extractQueryParameter(location: Uri, key: String): String { - return location.getQueryParameter(key) - ?: error("no parameter for key: $key") + fun extractQueryParameter(location: URI, key: String): String { + return location.query + .split("&") + .map { + val (k, v) = it.split("=", limit = 2) + Pair(k, v) + } + .find { it.first == key }?.second ?: error("no parameter for key: $key") } } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/AuthenticationData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/AuthenticationData.kt new file mode 100644 index 00000000..f20ec62c --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/AuthenticationData.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.api.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Device type. `gemF_Biometrie 4.1.2.2` + */ +@Serializable +data class DeviceType( + @SerialName("device_type_data_version") val version: String = "1.0", + @SerialName("manufacturer") val manufacturer: String, + @SerialName("product") val productName: String, + @SerialName("model") val model: String, + @SerialName("os") val operatingSystem: String, + @SerialName("os_version") val operatingSystemVersion: String +) + +/** + * Device information. `gemF_Biometrie 4.1.2.3` + */ +@Serializable +data class DeviceInformation( + @SerialName("device_information_data_version") val version: String = "1.0", + @SerialName("name") val name: String, // android device name set by user + @SerialName("device_type") val deviceType: DeviceType +) + +/** + * Pairing data. `gemF_Biometrie 4.1.2.4` + */ +@Serializable +data class PairingData( + @SerialName("pairing_data_version") val version: String = "1.0", + + @SerialName("se_subject_public_key_info") val subjectPublicKeyInfoOfSecureElement: String, + @SerialName("key_identifier") val keyAliasOfSecureElement: String, // alias of the keystore entry + @SerialName("product") val productName: String, + + @SerialName("serialnumber") val serialNumberOfHealthCard: String, + @SerialName("issuer") val issuerOfHealthCard: String, + @SerialName("not_after") val validityUntilOfHealthCard: Long, + + @SerialName("auth_cert_subject_public_key_info") val subjectPublicKeyInfoOfHealthCard: String +) + +/** + * Registration data. `gemF_Biometrie 4.1.2.6` + */ +@Serializable +data class RegistrationData( + @SerialName("registration_data_version") val version: String = "1.0", + @SerialName("signed_pairing_data") val signedPairingData: String, + @SerialName("auth_cert") val healthCardCertificate: String, + @SerialName("device_information") val deviceInformation: DeviceInformation +) + +/** + * Authentication data. `gemF_Biometrie 4.1.2.8` + */ +@Serializable +data class AuthenticationData( + @SerialName("authentication_data_version") val version: String = "1.0", + @SerialName("challenge_token") val challenge: String, + @SerialName("auth_cert") val healthCardCertificate: String, + @SerialName("key_identifier") val keyAliasOfSecureElement: String, // alias of the keystore entry + @SerialName("device_information") val deviceInformation: DeviceInformation, + @SerialName("amr") val authenticationMethod: List +) + +/** + * Pairing entry. `gemF_Biometrie 4.1.2.11` + */ +@Serializable +data class PairingResponseEntry( + @SerialName("pairing_entry_data_version") val version: String = "1.0", + @SerialName("name") val name: String, // android device name set by user + @SerialName("creation_time") val creationTime: Long, + @SerialName("signed_pairing_data") val signedPairingData: String +) + +/** + * Pairing entries. `gemF_Biometrie 4.1.2.12` + */ +@Serializable +data class PairingResponseEntries( + @SerialName("pairing_entries") val entries: List +) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/BasicData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/BasicData.kt new file mode 100644 index 00000000..6f8e71e7 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/BasicData.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +@file:UseSerializers(JWSSerializer::class) + +package de.gematik.ti.erp.app.idp.api.models + +import de.gematik.ti.erp.app.idp.api.IdpService +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.secureRandomInstance +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jose4j.base64url.Base64Url +import org.jose4j.jwk.JsonWebKey +import org.jose4j.jwk.PublicJsonWebKey +import org.jose4j.jws.JsonWebSignature +import java.net.URI + +@Serializable +data class IdpDiscoveryInfo( + @SerialName("authorization_endpoint") val authorizationURL: String, + @SerialName("sso_endpoint") val ssoURL: String, + @SerialName("token_endpoint") val tokenURL: String, + @SerialName("uri_pair") val pairingURL: String, + @SerialName("auth_pair_endpoint") val authenticationURL: String, + @SerialName("uri_puk_idp_enc") val uriPukIdpEnc: String, + @SerialName("uri_puk_idp_sig") val uriPukIdpSig: String, + @SerialName("exp") val expirationTime: Long, + @SerialName("iat") val issuedAt: Long, + @SerialName("kk_app_list_uri") val krankenkassenAppURL: String? = null, + @SerialName("third_party_authorization_endpoint") val thirdPartyAuthorizationURL: String? = null +) + +@Serializable +data class AuthenticationId( + @SerialName("kk_app_name") val name: String, + @SerialName("kk_app_id") val id: String +) + +@Serializable +data class AuthenticationIdList( + @SerialName("kk_app_list") val authenticationList: List +) + +@Serializable +data class AuthorizationRedirectInfo( + @SerialName("client_id") val clientId: String, + @SerialName("state") val state: String, + @SerialName("redirect_uri") val redirectUri: String, + @SerialName("code_challenge") val codeChallenge: String, + @SerialName("code_challenge_method") val codeChallengeMethod: String, + @SerialName("response_type") val responseType: String, + @SerialName("nonce") val nonce: String, + @SerialName("scope") val scope: String +) + +// TODO https://youtrack.jetbrains.com/issue/KT-50649 conflicts with result class of Kotlin +// @JvmInline +// value class JWSPublicKey(val jws: PublicJsonWebKey) +// +// @JvmInline +// value class JWSKey(val jws: JsonWebKey) + +class JWSPublicKey(val jws: PublicJsonWebKey) + +class JWSKey(val jws: JsonWebKey) + +data class JWSChallenge(val jws: JsonWebSignature, val raw: String) + +@Serializable +data class Challenge( + val challenge: JWSChallenge +) + +@Serializable +data class TokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Long, + @SerialName("id_token") val idToken: String, + @SerialName("sso_token") val ssoToken: String? = null, + @SerialName("token_type") val tokenType: String +) + +enum class IdpScope { + Default, + BiometricPairing +} + +data class IdpChallengeFlowResult( + val scope: IdpScope, + val challenge: IdpUnsignedChallenge +) + +data class IdpAuthFlowResult( + val accessToken: String, + val ssoToken: String, + val idTokenInsurantName: String, + val idTokenInsuranceIdentifier: String, + val idTokenInsuranceName: String +) + +data class IdpRefreshFlowResult( + val scope: IdpScope, + val accessToken: String +) + +data class IdpInitialData( + val config: IdpData.IdpConfiguration, + val pukSigKey: JWSPublicKey, + val pukEncKey: JWSPublicKey, + val state: IdpState, + val nonce: IdpNonce, + val codeVerifier: String, + val codeChallenge: String +) + +data class IdpUnsignedChallenge( + val signedChallenge: String, // raw jws + val challenge: String, // payload extracted from the jws + val expires: Long // expiry timestamp parsed from challenge +) + +data class IdpTokenResult( + val decryptedAccessToken: String, + val idTokenPayload: String +) + +@JvmInline +value class IdpState(val state: String) { + operator fun component1(): String = state + + companion object { + fun create(outLength: Int = 32) = IdpState(generateRandomUrlSafeStringSecure(outLength)) + } +} + +@JvmInline +value class IdpNonce(val nonce: String) { + operator fun component1(): String = nonce + + companion object { + fun create() = IdpNonce( + generateRandomUrlSafeStringSecure(32) + ) + } +} + +internal fun generateRandomUrlSafeStringSecure(outLength: Int = 32): String { + require(outLength >= 1) + val chars = Base64Url.encode( + ByteArray((outLength / 4 + 1) * 3).apply { + secureRandomInstance().nextBytes(this) + } + ) + return chars.substring(0 until outLength) +} +class ExternalAuthorizationData(uri: URI) { + val code = IdpService.extractQueryParameter(uri, "code") + val state = IdpService.extractQueryParameter(uri, "state") + val kkAppRedirectUri = IdpService.extractQueryParameter(uri, "kk_app_redirect_uri") +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/MoshiAdapters.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/Serializers.kt similarity index 58% rename from android/src/main/java/de/gematik/ti/erp/app/idp/api/models/MoshiAdapters.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/Serializers.kt index e76ee950..4066b850 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/api/models/MoshiAdapters.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/api/models/Serializers.kt @@ -18,21 +18,20 @@ package de.gematik.ti.erp.app.idp.api.models -import com.squareup.moshi.FromJson -import com.squareup.moshi.JsonWriter -import com.squareup.moshi.ToJson +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import org.jose4j.jws.JsonWebSignature import org.jose4j.jwx.JsonWebStructure -class JWSAdapter { - @FromJson - fun fromJson(jws: String): JWSChallenge { +object JWSSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JWSSerializer", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: JWSChallenge) = error("not implemented") + override fun deserialize(decoder: Decoder): JWSChallenge { + val jws = decoder.decodeString() return JWSChallenge(JsonWebStructure.fromCompactSerialization(jws) as JsonWebSignature, jws) } - - @Suppress("UNUSED_PARAMETER") - @ToJson - fun toJson(writer: JsonWriter, jws: JWSChallenge) { - error("not implemented") - } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/model/IdpData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/model/IdpData.kt new file mode 100644 index 00000000..13211222 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/model/IdpData.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.model + +import org.bouncycastle.cert.X509CertificateHolder +import org.jose4j.base64url.Base64Url +import org.jose4j.jwx.JsonWebStructure +import java.time.Duration +import java.time.Instant + +object IdpData { + data class IdpConfiguration( + var authorizationEndpoint: String, + var ssoEndpoint: String, + var tokenEndpoint: String, + var pairingEndpoint: String, + var authenticationEndpoint: String, + var pukIdpEncEndpoint: String, + var pukIdpSigEndpoint: String, + var certificate: X509CertificateHolder, + var expirationTimestamp: Instant, + var issueTimestamp: Instant, + var externalAuthorizationIDsEndpoint: String?, + var thirdPartyAuthorizationEndpoint: String? + ) + + data class SingleSignOnToken( + val token: String, + val expiresOn: Instant = extractExpirationTimestamp(token), + val validOn: Instant = extractValidOnTimestamp(token) + ) { + fun isValid(instant: Instant = Instant.now()) = + instant < expiresOn && instant >= validOn + } + + sealed interface SingleSignOnTokenScope { + val token: SingleSignOnToken? + } + + sealed interface TokenWithHealthCardScope : SingleSignOnTokenScope { + val cardAccessNumber: String + val healthCardCertificate: X509CertificateHolder + } + + sealed interface TokenWithKeyStoreAliasScope : TokenWithHealthCardScope { + val aliasOfSecureElementEntry: ByteArray + + fun aliasOfSecureElementEntryBase64(): String = + Base64Url.encode(aliasOfSecureElementEntry) // url safe for compatibility with response from idp backend + } + + data class DefaultToken( + override val token: SingleSignOnToken?, + override val cardAccessNumber: String, + override val healthCardCertificate: X509CertificateHolder + ) : TokenWithHealthCardScope { + constructor( + token: SingleSignOnToken?, + cardAccessNumber: String, + healthCardCertificate: ByteArray + ) : this( + token = token, + cardAccessNumber = cardAccessNumber, + healthCardCertificate = X509CertificateHolder(healthCardCertificate) + ) + } + + data class ExternalAuthenticationToken( + override val token: SingleSignOnToken?, + val authenticatorId: String, + val authenticatorName: String + ) : SingleSignOnTokenScope + + data class AlternateAuthenticationToken( + override val token: SingleSignOnToken?, + override val cardAccessNumber: String, + override val aliasOfSecureElementEntry: ByteArray, + override val healthCardCertificate: X509CertificateHolder + ) : TokenWithHealthCardScope, TokenWithKeyStoreAliasScope { + constructor( + token: SingleSignOnToken?, + cardAccessNumber: String, + aliasOfSecureElementEntry: ByteArray, + healthCardCertificate: ByteArray + ) : this( + token = token, + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + healthCardCertificate = X509CertificateHolder(healthCardCertificate) + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AlternateAuthenticationToken + + if (token != other.token) return false + if (cardAccessNumber != other.cardAccessNumber) return false + if (!aliasOfSecureElementEntry.contentEquals(other.aliasOfSecureElementEntry)) return false + if (healthCardCertificate != other.healthCardCertificate) return false + + return true + } + + override fun hashCode(): Int { + var result = token?.hashCode() ?: 0 + result = 31 * result + cardAccessNumber.hashCode() + result = 31 * result + aliasOfSecureElementEntry.contentHashCode() + result = 31 * result + healthCardCertificate.hashCode() + return result + } + } + + data class AlternateAuthenticationWithoutToken( + override val cardAccessNumber: String, + override val aliasOfSecureElementEntry: ByteArray, + override val healthCardCertificate: X509CertificateHolder + ) : TokenWithHealthCardScope, TokenWithKeyStoreAliasScope { + override val token: SingleSignOnToken? = null + + constructor( + cardAccessNumber: String, + aliasOfSecureElementEntry: ByteArray, + healthCardCertificate: ByteArray + ) : this( + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + healthCardCertificate = X509CertificateHolder(healthCardCertificate) + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AlternateAuthenticationWithoutToken + + if (cardAccessNumber != other.cardAccessNumber) return false + if (!aliasOfSecureElementEntry.contentEquals(other.aliasOfSecureElementEntry)) return false + if (healthCardCertificate != other.healthCardCertificate) return false + if (token != other.token) return false + + return true + } + + override fun hashCode(): Int { + var result = cardAccessNumber.hashCode() + result = 31 * result + aliasOfSecureElementEntry.contentHashCode() + result = 31 * result + healthCardCertificate.hashCode() + result = 31 * result + (token?.hashCode() ?: 0) + return result + } + } + + data class AuthenticationData( + val singleSignOnTokenScope: SingleSignOnTokenScope? + ) +} + +fun extractExpirationTimestamp(ssoToken: String): Instant = + Instant.ofEpochSecond( + JsonWebStructure + .fromCompactSerialization(ssoToken) + .headers + .getLongHeaderValue("exp") + ) + +fun extractValidOnTimestamp(ssoToken: String): Instant = + extractExpirationTimestamp(ssoToken) - Duration.ofHours(24) diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpLocalDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpLocalDataSource.kt new file mode 100644 index 00000000..322c231f --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpLocalDataSource.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.repository + +import de.gematik.ti.erp.app.db.entities.v1.IdpAuthenticationDataEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpConfigurationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SingleSignOnTokenScopeV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.db.writeOrCopyToRealm +import de.gematik.ti.erp.app.db.writeToRealm +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import io.github.aakira.napier.Napier +import io.realm.kotlin.MutableRealm +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import kotlinx.coroutines.flow.map +import org.bouncycastle.cert.X509CertificateHolder +import java.security.KeyStore + +class IdpLocalDataSource constructor( + private val realm: Realm +) { + suspend fun saveIdpInfo(config: IdpData.IdpConfiguration) { + realm.writeOrCopyToRealm(::IdpConfigurationEntityV1) { entity -> + entity.authorizationEndpoint = config.authorizationEndpoint + entity.ssoEndpoint = config.ssoEndpoint + entity.tokenEndpoint = config.tokenEndpoint + entity.pairingEndpoint = config.pairingEndpoint + entity.authenticationEndpoint = config.authenticationEndpoint + entity.pukIdpEncEndpoint = config.pukIdpEncEndpoint + entity.pukIdpSigEndpoint = config.pukIdpSigEndpoint + entity.certificateX509 = config.certificate.encoded + entity.expirationTimestamp = config.expirationTimestamp.toRealmInstant() + entity.issueTimestamp = config.issueTimestamp.toRealmInstant() + entity.externalAuthorizationIDsEndpoint = config.externalAuthorizationIDsEndpoint + entity.thirdPartyAuthorizationEndpoint = config.thirdPartyAuthorizationEndpoint + } + } + + fun loadIdpInfo(): IdpData.IdpConfiguration? = + realm.queryFirst()?.let { + IdpData.IdpConfiguration( + authorizationEndpoint = it.authorizationEndpoint, + ssoEndpoint = it.ssoEndpoint, + tokenEndpoint = it.tokenEndpoint, + pairingEndpoint = it.pairingEndpoint, + authenticationEndpoint = it.authenticationEndpoint, + pukIdpEncEndpoint = it.pukIdpEncEndpoint, + pukIdpSigEndpoint = it.pukIdpSigEndpoint, + certificate = X509CertificateHolder(it.certificateX509), + expirationTimestamp = it.expirationTimestamp.toInstant(), + issueTimestamp = it.issueTimestamp.toInstant(), + externalAuthorizationIDsEndpoint = it.externalAuthorizationIDsEndpoint, + thirdPartyAuthorizationEndpoint = it.thirdPartyAuthorizationEndpoint + ) + } + + suspend fun invalidateConfiguration() { + realm.writeToRealm { config -> + delete(config) + } + } + + suspend fun saveSingleSignOnToken( + profileId: ProfileIdentifier, + tokenScope: IdpData.SingleSignOnTokenScope + ) { + writeToRealm(profileId) { profile -> + val actualToken = tokenScope.token?.token + val scope = when (tokenScope) { + is IdpData.ExternalAuthenticationToken -> SingleSignOnTokenScopeV1.ExternalAuthentication + is IdpData.AlternateAuthenticationToken -> SingleSignOnTokenScopeV1.AlternateAuthentication + is IdpData.AlternateAuthenticationWithoutToken -> SingleSignOnTokenScopeV1.AlternateAuthentication + is IdpData.DefaultToken -> SingleSignOnTokenScopeV1.Default + } + val can = when (tokenScope) { + is IdpData.TokenWithHealthCardScope -> tokenScope.cardAccessNumber + else -> "" + } + val cert = when (tokenScope) { + is IdpData.TokenWithHealthCardScope -> tokenScope.healthCardCertificate.encoded + else -> null + } + val alias = when (tokenScope) { + is IdpData.AlternateAuthenticationToken -> tokenScope.aliasOfSecureElementEntry + is IdpData.AlternateAuthenticationWithoutToken -> tokenScope.aliasOfSecureElementEntry + else -> null + } + val authId = when (tokenScope) { + is IdpData.ExternalAuthenticationToken -> tokenScope.authenticatorId + else -> null + } + val authName = when (tokenScope) { + is IdpData.ExternalAuthenticationToken -> tokenScope.authenticatorName + else -> null + } + + getOrInsertAuthData(profile)?.apply { + this.singleSignOnToken = actualToken + this.singleSignOnTokenScope = scope + + this.cardAccessNumber = can + this.healthCardCertificate = cert + this.aliasOfSecureElementEntry = alias + + this.externalAuthenticatorId = authId + this.externalAuthenticatorName = authName + } + } + } + + suspend fun invalidateSingleSignOnTokenRetainingScope(profileId: ProfileIdentifier) { + writeToRealm(profileId) { profile -> + getOrInsertAuthData(profile)?.apply { + this.singleSignOnToken = null + } + } + } + + suspend fun invalidateAuthenticationData(profileId: ProfileIdentifier) { + writeToRealm(profileId) { profile -> + getOrInsertAuthData(profile)?.apply { + try { + this.aliasOfSecureElementEntry?.also { + KeyStore.getInstance("AndroidKeyStore") + .apply { load(null) } + .deleteEntry(it.decodeToString()) + } + } catch (e: Exception) { + // silent fail; expected + } + + delete(this) + } + } + } + + fun authenticationData(profileId: ProfileIdentifier) = + realm.query("id = $0", profileId) + .first() + .asFlow() + .map { profile -> + IdpData.AuthenticationData( + singleSignOnTokenScope = profile.obj?.idpAuthenticationData?.toSingleSignOnTokenScope() + ) + } + + private suspend fun writeToRealm(profileId: ProfileIdentifier, block: MutableRealm.(ProfileEntityV1) -> Unit) { + realm.writeToRealm("id == $0", profileId) { + block(it) + } + } + + private fun MutableRealm.getOrInsertAuthData(profile: ProfileEntityV1) = + if (profile.idpAuthenticationData == null) { + copyToRealm(IdpAuthenticationDataEntityV1()).also { + profile.idpAuthenticationData = it + } + } else { + profile.idpAuthenticationData + } +} + +fun IdpAuthenticationDataEntityV1.toSingleSignOnTokenScope(): IdpData.SingleSignOnTokenScope? = + try { + when (this.singleSignOnTokenScope) { + SingleSignOnTokenScopeV1.Default -> + IdpData.DefaultToken( + token = this.singleSignOnToken?.let { token -> IdpData.SingleSignOnToken(token) }, + cardAccessNumber = this.cardAccessNumber, + healthCardCertificate = requireNotNull(this.healthCardCertificate) + ) + SingleSignOnTokenScopeV1.AlternateAuthentication -> + this.singleSignOnToken?.let { token -> + IdpData.AlternateAuthenticationToken( + token = IdpData.SingleSignOnToken(token), + cardAccessNumber = this.cardAccessNumber, + healthCardCertificate = requireNotNull(this.healthCardCertificate), + aliasOfSecureElementEntry = requireNotNull(this.aliasOfSecureElementEntry) + ) + } ?: IdpData.AlternateAuthenticationWithoutToken( + cardAccessNumber = this.cardAccessNumber, + aliasOfSecureElementEntry = requireNotNull(this.aliasOfSecureElementEntry), + healthCardCertificate = requireNotNull(this.healthCardCertificate) + ) + SingleSignOnTokenScopeV1.ExternalAuthentication -> + IdpData.ExternalAuthenticationToken( + token = this.singleSignOnToken?.let { token -> IdpData.SingleSignOnToken(token) }, + authenticatorId = requireNotNull(this.externalAuthenticatorId), + authenticatorName = requireNotNull(this.externalAuthenticatorName) + ) + } + } catch (e: IllegalArgumentException) { + Napier.e("IDP auth data is in a inconsistent state", e) + null + } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpPairingRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpPairingRepository.kt new file mode 100644 index 00000000..2528de29 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpPairingRepository.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.repository + +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +class IdpPairingRepository constructor( + private val localDataSource: IdpLocalDataSource +) { + private val decryptedAccessTokenMap: MutableStateFlow> = + MutableStateFlow(mutableMapOf()) + private val singleSignOnTokenMap: MutableStateFlow> = + MutableStateFlow(mutableMapOf()) + + fun decryptedAccessToken(profileId: ProfileIdentifier) = + decryptedAccessTokenMap.map { it[profileId] }.distinctUntilChanged() + + fun saveDecryptedAccessToken(profileId: ProfileIdentifier, accessToken: String) { + decryptedAccessTokenMap.update { + it + (profileId to accessToken) + } + } + + fun invalidateDecryptedAccessToken(profileId: ProfileIdentifier) { + decryptedAccessTokenMap.update { + it - profileId + } + } + + /** + * This function fuses the scope of the original prescription token with the token scoped to pairing. + */ + fun singleSignOnTokenScope(profileId: ProfileIdentifier) = + combine( + localDataSource.authenticationData(profileId), + singleSignOnTokenMap + .map { it[profileId] } + .distinctUntilChanged() + ) { authData, pairingToken -> + when (val originalToken = authData.singleSignOnTokenScope) { + is IdpData.ExternalAuthenticationToken -> + pairingToken?.let { + IdpData.ExternalAuthenticationToken( + token = it, + authenticatorId = originalToken.authenticatorId, + authenticatorName = originalToken.authenticatorName + ) + } + is IdpData.AlternateAuthenticationToken -> + pairingToken?.let { + IdpData.AlternateAuthenticationToken( + token = it, + cardAccessNumber = originalToken.cardAccessNumber, + aliasOfSecureElementEntry = originalToken.aliasOfSecureElementEntry, + healthCardCertificate = originalToken.healthCardCertificate + ) + } + is IdpData.AlternateAuthenticationWithoutToken -> + if (pairingToken == null) { + originalToken + } else { + IdpData.AlternateAuthenticationToken( + token = pairingToken, + cardAccessNumber = originalToken.cardAccessNumber, + aliasOfSecureElementEntry = originalToken.aliasOfSecureElementEntry, + healthCardCertificate = originalToken.healthCardCertificate + ) + } + is IdpData.DefaultToken -> + pairingToken?.let { + IdpData.DefaultToken( + token = it, + cardAccessNumber = originalToken.cardAccessNumber, + healthCardCertificate = originalToken.healthCardCertificate + ) + } + null -> null + } + } + + fun saveSingleSignOnToken(profileId: ProfileIdentifier, token: IdpData.SingleSignOnToken) { + singleSignOnTokenMap.update { + it + (profileId to token) + } + } + + fun invalidateSingleSignOnToken(profileId: ProfileIdentifier) { + singleSignOnTokenMap.update { + it - profileId + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt similarity index 72% rename from android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt index 566c1844..2f7e2359 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt @@ -18,22 +18,20 @@ package de.gematik.ti.erp.app.idp.repository +import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.api.ApiCallException -import de.gematik.ti.erp.app.api.Result import de.gematik.ti.erp.app.api.safeApiCall import de.gematik.ti.erp.app.api.safeApiCallRaw import de.gematik.ti.erp.app.idp.api.IdpService -import de.gematik.ti.erp.app.idp.api.REDIRECT_URI -import de.gematik.ti.erp.app.idp.usecase.IdpUseCase +import de.gematik.ti.erp.app.idp.api.models.ExternalAuthorizationData import okhttp3.ResponseBody import retrofit2.Response import java.net.HttpURLConnection -import javax.inject.Inject -private const val defaultScope = "e-rezept openid" +private val defaultScope = BuildKonfig.IDP_DEFAULT_SCOPE private const val pairingScope = "pairing openid" -class IdpRemoteDataSource @Inject constructor( +class IdpRemoteDataSource constructor( private val service: IdpService ) { @@ -59,13 +57,15 @@ class IdpRemoteDataSource @Inject constructor( nonce: String, state: String, codeChallenge: String, + isPairingScope: Boolean ) = postToEndpointExpectingLocationRedirect { service.requestAuthenticationRedirect( - url, + url = url, externalAppId = externalAppId, codeChallenge = codeChallenge, nonce = nonce, - state = state + state = state, + scope = if (isPairingScope) pairingScope else defaultScope ) } @@ -74,23 +74,47 @@ class IdpRemoteDataSource @Inject constructor( codeChallenge: String, state: String, nonce: String, - isDeviceRegistration: Boolean + isDeviceRegistration: Boolean, + redirectUri: String ) = safeApiCall("error loading challenge") { service.fetchTokenChallenge( - url, + url = url, codeChallenge = codeChallenge, state = state, nonce = nonce, - scope = if (isDeviceRegistration) pairingScope else defaultScope + scope = if (isDeviceRegistration) pairingScope else defaultScope, + redirectUri = redirectUri ) } suspend fun postPairing(url: String, token: String, encryptedRegistrationData: String) = safeApiCall("failed to pair device") { - service.pairing(url, "Bearer $token", encryptedRegistrationData) + service.postPairing(url, "Bearer $token", encryptedRegistrationData) + } + + suspend fun getPairing(url: String, token: String) = + safeApiCall("failed to get paired devices") { + service.getPairing(url, "Bearer $token") } + // tag::DeletePairedDevicesRepository[] + suspend fun deletePairing(url: String, token: String, alias: String) = + safeApiCallRaw("failed to delete paired device") { + val response = service.deletePairing("$url/$alias", "Bearer $token") + if (response.code() == HttpURLConnection.HTTP_NO_CONTENT) { + Result.success(Unit) + } else { + Result.failure( + ApiCallException( + "Expected no content but received: ${response.code()} ${response.message()}", + response + ) + ) + } + } + // end::DeletePairedDevicesRepository[] + /** * Authorization with Card */ @@ -111,13 +135,13 @@ class IdpRemoteDataSource @Inject constructor( */ suspend fun authorizeExtern( url: String, - externalAuthorizationData: IdpUseCase.ExternalAuthorizationData + externalAuthorizationData: ExternalAuthorizationData ) = postToEndpointExpectingLocationRedirect { service.externalAuthorization( url = url, code = externalAuthorizationData.code, state = externalAuthorizationData.state, - kk_app_redirect_uri = externalAuthorizationData.kkAppRedirectUri + redirectUri = externalAuthorizationData.kkAppRedirectUri ) } @@ -132,16 +156,18 @@ class IdpRemoteDataSource @Inject constructor( ) } - private suspend inline fun postToEndpointExpectingLocationRedirect(crossinline call: suspend () -> Response) = + private suspend inline fun postToEndpointExpectingLocationRedirect( + crossinline call: suspend () -> Response + ) = safeApiCallRaw("error posting to redirecting endpoint") { val response = call() if (response.code() == HttpURLConnection.HTTP_MOVED_TEMP) { val headers = response.headers() val location = requireNotNull(headers["Location"]) - Result.Success(location) + Result.success(location) } else { - Result.Error( + Result.failure( ApiCallException( "Expected redirect ${response.code()} ${response.message()}", response @@ -154,7 +180,7 @@ class IdpRemoteDataSource @Inject constructor( url: String, keyVerifier: String, code: String, - redirectUri: String = REDIRECT_URI + redirectUri: String ) = safeApiCall("error posting for token") { service.token( url = url, diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt new file mode 100644 index 00000000..8b20bbce --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.repository + +import de.gematik.ti.erp.app.idp.api.models.AuthenticationId +import de.gematik.ti.erp.app.idp.api.models.AuthenticationIdList +import de.gematik.ti.erp.app.idp.api.models.Challenge +import de.gematik.ti.erp.app.idp.api.models.ExternalAuthorizationData +import de.gematik.ti.erp.app.idp.api.models.IdpDiscoveryInfo +import de.gematik.ti.erp.app.idp.api.models.IdpNonce +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.idp.api.models.IdpState +import de.gematik.ti.erp.app.idp.api.models.JWSPublicKey +import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntries +import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntry +import de.gematik.ti.erp.app.idp.api.models.TokenResponse +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.vau.extractECPublicKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.bouncycastle.cert.X509CertificateHolder +import org.jose4j.base64url.Base64 +import org.jose4j.jws.JsonWebSignature +import java.security.PublicKey +import java.time.Instant + +@JvmInline +value class JWSDiscoveryDocument(val jws: JsonWebSignature) + +class IdpRepository constructor( + private val remoteDataSource: IdpRemoteDataSource, + private val localDataSource: IdpLocalDataSource +) { + private val json = Json { ignoreUnknownKeys = true } + private val decryptedAccessTokenMap: MutableStateFlow> = MutableStateFlow(mutableMapOf()) + + fun decryptedAccessToken(profileId: ProfileIdentifier) = + decryptedAccessTokenMap.map { it[profileId] }.distinctUntilChanged() + + fun saveDecryptedAccessToken(profileId: ProfileIdentifier, accessToken: String) { + decryptedAccessTokenMap.update { + it + (profileId to accessToken) + } + } + + suspend fun saveSingleSignOnToken(profileId: ProfileIdentifier, token: IdpData.SingleSignOnTokenScope) { + localDataSource.saveSingleSignOnToken(profileId, token) + } + + fun authenticationData(profileId: ProfileIdentifier): Flow = + localDataSource.authenticationData(profileId) + + suspend fun fetchChallenge( + url: String, + codeChallenge: String, + state: String, + nonce: String, + isDeviceRegistration: Boolean, + redirectUri: String + ): Result = + remoteDataSource.fetchChallenge( + url = url, + codeChallenge = codeChallenge, + state = state, + nonce = nonce, + isDeviceRegistration = isDeviceRegistration, + redirectUri = redirectUri + ) + + /** + * Returns an unchecked and possible invalid idp configuration parsed from the discovery document. + */ + suspend fun loadUncheckedIdpConfiguration(): IdpData.IdpConfiguration { + return localDataSource.loadIdpInfo() ?: run { + extractUncheckedIdpConfiguration( + remoteDataSource.fetchDiscoveryDocument().getOrThrow() + ).also { localDataSource.saveIdpInfo(it) } + } + } + + suspend fun postSignedChallenge(url: String, signedChallenge: String): Result = + remoteDataSource.postChallenge(url, signedChallenge) + + suspend fun postUnsignedChallengeWithSso( + url: String, + ssoToken: String, + unsignedChallenge: String + ): Result = + remoteDataSource.postChallenge(url, ssoToken, unsignedChallenge) + + suspend fun postToken( + url: String, + keyVerifier: String, + code: String, + redirectUri: String + ): Result = + remoteDataSource.postToken( + url, + keyVerifier = keyVerifier, + code = code, + redirectUri = redirectUri + ) + + suspend fun fetchExternalAuthorizationIDList( + url: String, + idpPukSigKey: PublicKey + ): List { + val jwtResult = remoteDataSource.fetchExternalAuthorizationIDList(url).getOrThrow() + + return extractAuthenticationIDList(jwtResult.apply { key = idpPukSigKey }.payload) + } + + suspend fun fetchIdpPukSig(url: String): Result = + remoteDataSource.fetchIdpPukSig(url) + + suspend fun fetchIdpPukEnc(url: String): Result = + remoteDataSource.fetchIdpPukEnc(url) + + private fun parseDiscoveryDocumentBody(body: String): IdpDiscoveryInfo = + json.decodeFromString(body) + + private fun extractAuthenticationIDList(payload: String): List { + return json.decodeFromString(payload).authenticationList + } + + private fun extractUncheckedIdpConfiguration(discoveryDocument: JWSDiscoveryDocument): IdpData.IdpConfiguration { + val x5c = requireNotNull( + (discoveryDocument.jws.headers?.getObjectHeaderValue("x5c") as? ArrayList<*>)?.firstOrNull() as? String + ) { "Missing certificate" } + val certificateHolder = X509CertificateHolder(Base64.decode(x5c)) + + discoveryDocument.jws.key = certificateHolder.extractECPublicKey() + + val discoveryDocumentBody = parseDiscoveryDocumentBody(discoveryDocument.jws.payload) + + return IdpData.IdpConfiguration( + authorizationEndpoint = overwriteEndpoint(discoveryDocumentBody.authorizationURL), + ssoEndpoint = overwriteEndpoint(discoveryDocumentBody.ssoURL), + tokenEndpoint = overwriteEndpoint(discoveryDocumentBody.tokenURL), + pairingEndpoint = discoveryDocumentBody.pairingURL, + authenticationEndpoint = overwriteEndpoint(discoveryDocumentBody.authenticationURL), + pukIdpEncEndpoint = overwriteEndpoint(discoveryDocumentBody.uriPukIdpEnc), + pukIdpSigEndpoint = overwriteEndpoint(discoveryDocumentBody.uriPukIdpSig), + expirationTimestamp = Instant.ofEpochSecond(discoveryDocumentBody.expirationTime), + issueTimestamp = Instant.ofEpochSecond(discoveryDocumentBody.issuedAt), + certificate = certificateHolder, + externalAuthorizationIDsEndpoint = overwriteEndpoint(discoveryDocumentBody.krankenkassenAppURL), + thirdPartyAuthorizationEndpoint = overwriteEndpoint(discoveryDocumentBody.thirdPartyAuthorizationURL) + ) + } + + private fun overwriteEndpoint(oldEndpoint: String?) = + oldEndpoint?.replace(".zentral.idp.splitdns.ti-dienste.de", ".app.ti-dienste.de") ?: "" + + suspend fun postPairing( + url: String, + encryptedRegistrationData: String, + token: String + ): Result = + remoteDataSource.postPairing( + url, + token = token, + encryptedRegistrationData = encryptedRegistrationData + ) + + suspend fun getPairing( + url: String, + token: String + ): Result = + remoteDataSource.getPairing( + url, + token = token + ) + + suspend fun deletePairing( + url: String, + token: String, + alias: String + ): Result = + remoteDataSource.deletePairing( + url = url, + token = token, + alias = alias + ) + + suspend fun postBiometricAuthenticationData( + url: String, + encryptedSignedAuthenticationData: String + ): Result = + remoteDataSource.authorizeBiometric(url, encryptedSignedAuthenticationData) + + suspend fun postExternAppAuthorizationData( + url: String, + externalAuthorizationData: ExternalAuthorizationData + ): Result = + remoteDataSource.authorizeExtern( + url = url, + externalAuthorizationData = externalAuthorizationData + ) + + suspend fun invalidate(profileId: ProfileIdentifier) { + invalidateConfig() + invalidateDecryptedAccessToken(profileId) + localDataSource.invalidateAuthenticationData(profileId) + } + + suspend fun invalidateConfig() { + localDataSource.invalidateConfiguration() + } + + suspend fun invalidateSingleSignOnTokenRetainingScope(profileId: ProfileIdentifier) { + localDataSource.invalidateSingleSignOnTokenRetainingScope(profileId) + invalidateDecryptedAccessToken(profileId) + } + + fun invalidateDecryptedAccessToken(profileId: ProfileIdentifier) { + decryptedAccessTokenMap.update { + it - profileId + } + } + + suspend fun getAuthorizationRedirect( + url: String, + state: IdpState, + codeChallenge: String, + nonce: IdpNonce, + kkAppId: String, + scope: IdpScope + ): String { + return remoteDataSource.requestAuthorizationRedirect( + url = url, + externalAppId = kkAppId, + codeChallenge = codeChallenge, + nonce = nonce.nonce, + state = state.state, + isPairingScope = scope == IdpScope.BiometricPairing + ).getOrThrow() + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/ExternalAuthenticationPreferences.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/ExternalAuthenticationPreferences.kt new file mode 100644 index 00000000..b299248b --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/ExternalAuthenticationPreferences.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +data class ExternalAuthenticationPreferences( + val extAuthCodeChallenge: String? = null, + val extAuthCodeVerifier: String? = null, + val extAuthState: String? = null, + val extAuthNonce: String? = null, + val extAuthId: String? = null, + val extAuthScope: String? = null, + val extAuthName: String? = null, + val extAuthProfile: String? = null +) + +fun IdpPreferenceProvider.clear() { + externalAuthenticationPreferences = ExternalAuthenticationPreferences() +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpAlternateAuthenticationUseCase.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpAlternateAuthenticationUseCase.kt similarity index 69% rename from android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpAlternateAuthenticationUseCase.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpAlternateAuthenticationUseCase.kt index 812799cd..e43dc139 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpAlternateAuthenticationUseCase.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpAlternateAuthenticationUseCase.kt @@ -18,19 +18,25 @@ package de.gematik.ti.erp.app.idp.usecase -import android.net.Uri -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.api.Result import de.gematik.ti.erp.app.idp.api.IdpService +import de.gematik.ti.erp.app.idp.api.REDIRECT_URI import de.gematik.ti.erp.app.idp.api.models.AuthenticationData import de.gematik.ti.erp.app.idp.api.models.DeviceInformation import de.gematik.ti.erp.app.idp.api.models.DeviceType +import de.gematik.ti.erp.app.idp.api.models.IdpAuthFlowResult +import de.gematik.ti.erp.app.idp.api.models.IdpInitialData +import de.gematik.ti.erp.app.idp.api.models.IdpState +import de.gematik.ti.erp.app.idp.api.models.IdpUnsignedChallenge import de.gematik.ti.erp.app.idp.api.models.PairingData import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntry import de.gematik.ti.erp.app.idp.api.models.RegistrationData import de.gematik.ti.erp.app.idp.buildJsonWebSignatureWithHealthCard import de.gematik.ti.erp.app.idp.buildJsonWebSignatureWithSecureElement import de.gematik.ti.erp.app.idp.repository.IdpRepository +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.net.URI import org.bouncycastle.asn1.ASN1Sequence import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo import org.bouncycastle.cert.X509CertificateHolder @@ -40,20 +46,23 @@ import org.jose4j.jwe.JsonWebEncryption import org.jose4j.jwe.KeyManagementAlgorithmIdentifiers import org.jose4j.jws.JsonWebSignature import org.jose4j.jwx.JsonWebStructure -import org.json.JSONObject -import timber.log.Timber +import io.github.aakira.napier.Napier +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.security.PrivateKey import java.security.PublicKey import java.security.Signature -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class IdpAlternateAuthenticationUseCase @Inject constructor( - private val moshi: Moshi, +class IdpAlternateAuthenticationUseCase( private val basicUseCase: IdpBasicUseCase, - private val repository: IdpRepository + private val repository: IdpRepository, + private val deviceInfo: IdpDeviceInfoProvider ) { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } suspend fun registerDeviceWithHealthCard( initialData: IdpInitialData, @@ -63,13 +72,13 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( publicKeyOfSecureElementEntry: PublicKey, aliasOfSecureElementEntry: ByteArray, - signWithHealthCard: suspend (hash: ByteArray) -> ByteArray, + signWithHealthCard: suspend (hash: ByteArray) -> ByteArray ): PairingResponseEntry { val (config, pukSigKey, pukEncKey) = initialData // TODO phone name? shall we support a real user chosen name? - val deviceInformation = buildDeviceInformation("Some Android") - Timber.d("Device information: $deviceInformation") + val deviceInformation = buildDeviceInformation(deviceInfo.deviceName) + Napier.d("Device information: $deviceInformation") val healthCardCertificateHolder = X509CertificateHolder(healthCardCertificate) @@ -88,21 +97,68 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( buildEncryptedRegistrationData(registrationData, pukEncKey.jws.publicKey) val encryptedAccessToken = buildEncryptedAccessToken( - accessToken, idpPukSigKey = pukSigKey.jws.publicKey, idpPukEncKey = pukEncKey.jws.publicKey, + accessToken, + idpPukSigKey = pukSigKey.jws.publicKey, + idpPukEncKey = pukEncKey.jws.publicKey ) - return when ( - val r = repository.postPairing( - config.pairingEndpoint, - encryptedRegistrationData.compactSerialization, - encryptedAccessToken.compactSerialization - ) - ) { - is Result.Success -> r.data - is Result.Error -> throw r.exception + return repository.postPairing( + config.pairingEndpoint, + encryptedRegistrationData.compactSerialization, + encryptedAccessToken.compactSerialization + ).getOrThrow() + } + + suspend fun getPairedDevices( + initialData: IdpInitialData, + accessToken: String + ): List> { + val (config, pukSigKey, pukEncKey) = initialData + + val encryptedAccessToken = buildEncryptedAccessToken( + accessToken, + idpPukSigKey = pukSigKey.jws.publicKey, + idpPukEncKey = pukEncKey.jws.publicKey + ) + + val pairedDevices = repository.getPairing( + config.pairingEndpoint, + encryptedAccessToken.compactSerialization + ).getOrThrow() + + return pairedDevices.entries.map { + val pairingData = requireNotNull( + json.decodeFromString( + (JsonWebStructure.fromCompactSerialization(it.signedPairingData) as JsonWebSignature).unverifiedPayload + ) + ) { "Couldn't parse pairing data" } + + it to pairingData } } +// tag::DeletePairedDevicesUseCase[] + suspend fun deletePairedDevice( + initialData: IdpInitialData, + accessToken: String, + deviceAlias: String + ) { + val (config, pukSigKey, pukEncKey) = initialData + + val encryptedAccessToken = buildEncryptedAccessToken( + accessToken, + idpPukSigKey = pukSigKey.jws.publicKey, + idpPukEncKey = pukEncKey.jws.publicKey + ) + + repository.deletePairing( + url = config.pairingEndpoint, + token = encryptedAccessToken.compactSerialization, + alias = deviceAlias + ).getOrThrow() + } + // end::DeletePairedDevicesUseCase[] + fun buildEncryptedAccessToken( accessToken: String, idpPukSigKey: PublicKey, @@ -112,7 +168,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( val accessTokenExpiry = (JsonWebStructure.fromCompactSerialization(accessToken) as JsonWebSignature).let { it.key = idpPukSigKey - JSONObject(it.payload)["exp"] as Int + json.parseToJsonElement(it.payload).jsonObject["exp"]?.jsonPrimitive?.int } return JsonWebEncryption().apply { @@ -133,12 +189,12 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( aliasOfSecureElementEntry: ByteArray, privateKeyOfSecureElementEntry: PrivateKey, - signatureObjectOfSecureElementEntry: Signature, + signatureObjectOfSecureElementEntry: Signature ): IdpAuthFlowResult { val (config, pukSigKey, pukEncKey, state, nonce) = initialData val codeVerifier = initialData.codeVerifier - val deviceInformation = buildDeviceInformation("Some Android") + val deviceInformation = buildDeviceInformation(deviceInfo.deviceName) val authData = buildAuthenticationData( challenge.signedChallenge, @@ -150,7 +206,11 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( val signedAuthData = buildSignedAuthenticationData(authData, privateKeyOfSecureElementEntry, signatureObjectOfSecureElementEntry) val encryptedAuthData = - buildEncryptedSignedAuthenticationData(signedAuthData, challenge.expires, initialData.pukEncKey.jws.publicKey) + buildEncryptedSignedAuthenticationData( + signedAuthData, + challenge.expires, + initialData.pukEncKey.jws.publicKey + ) val redirect = postAlternateSignedChallengeAndGetRedirect( config.authenticationEndpoint, @@ -169,10 +229,11 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( codeVerifier = codeVerifier, code = redirectCodeJwe, pukEncKey = pukEncKey, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = REDIRECT_URI ) - val idTokenJson = JSONObject( + val idTokenJson = Json.parseToJsonElement( idpTokenResult.idTokenPayload ) @@ -180,26 +241,21 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( return IdpAuthFlowResult( accessToken = idpTokenResult.decryptedAccessToken, ssoToken = redirectSsoToken, - idTokenInsuranceIdentifier = idTokenJson.getStringOrNull("idNummer") ?: "", - idTokenInsuranceName = idTokenJson.getStringOrNull("organizationName") ?: "", - idTokenInsurantName = idTokenJson.getStringOrNull("given_name")?.let { it + " " + idTokenJson.getString("family_name") } ?: "" + idTokenInsuranceIdentifier = idTokenJson.jsonObject["idNummer"]?.jsonPrimitive?.content ?: "", + idTokenInsuranceName = idTokenJson.jsonObject["organizationName"]?.jsonPrimitive?.content ?: "", + idTokenInsurantName = idTokenJson.jsonObject["given_name"]?.jsonPrimitive?.content?.let { + it + " " + idTokenJson.jsonObject["family_name"]?.jsonPrimitive?.content + } ?: "" ) } suspend fun postAlternateSignedChallengeAndGetRedirect( url: String, codeChallenge: JsonWebEncryption, - state: IdpState, - ): Uri { - val redirect = when ( - val r = - repository.postBiometricAuthenticationData(url, codeChallenge.compactSerialization) - ) { - is Result.Success -> { - Uri.parse(r.data) - } - is Result.Error -> throw r.exception - } + state: IdpState + ): URI { + val redirect = + URI(repository.postBiometricAuthenticationData(url, codeChallenge.compactSerialization).getOrThrow()) val redirectState = IdpService.extractQueryParameter(redirect, "state") require(state.state == redirectState) { "Invalid state" } @@ -209,11 +265,11 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( fun buildDeviceType(): DeviceType = DeviceType( - manufacturer = android.os.Build.MANUFACTURER, - productName = android.os.Build.PRODUCT, - model = android.os.Build.MODEL, - operatingSystem = "Android", - operatingSystemVersion = android.os.Build.VERSION.SDK_INT.toString() + manufacturer = deviceInfo.manufacturer, + productName = deviceInfo.productName, + model = deviceInfo.model, + operatingSystem = deviceInfo.operatingSystem, + operatingSystemVersion = deviceInfo.operatingSystemVersion ) fun buildDeviceInformation(userChosenName: String): DeviceInformation { @@ -226,7 +282,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( fun buildPairingData( keyAliasOfSecureElement: ByteArray, subjectPublicKeyInfoOfSecureElement: SubjectPublicKeyInfo, - healthCardCertificate: X509CertificateHolder, + healthCardCertificate: X509CertificateHolder ): PairingData { require(keyAliasOfSecureElement.size == 32) @@ -235,7 +291,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( subjectPublicKeyInfoOfSecureElement.toASN1Primitive().encoded ), keyAliasOfSecureElement = Base64Url.encode(keyAliasOfSecureElement), - productName = android.os.Build.PRODUCT, + productName = deviceInfo.productName, serialNumberOfHealthCard = healthCardCertificate.serialNumber.toString(), issuerOfHealthCard = Base64Url.encode( @@ -256,7 +312,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( { algorithmHeaderValue = "BP256R1" setHeader("typ", "JWT") - payload = moshi.adapter(PairingData::class.java).toJson(pairingData) + payload = json.encodeToString(pairingData) }, sign ) @@ -282,7 +338,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( encryptionMethodHeaderParameter = ContentEncryptionAlgorithmIdentifiers.AES_256_GCM setHeader("typ", "JWT") key = idpPukEncKey // KeyFactorySpi.EC().generatePublic(healthCardCertificate.subjectPublicKeyInfo) - payload = moshi.adapter(RegistrationData::class.java).toJson(registrationData) + payload = json.encodeToString(registrationData) } enum class AuthenticationMethod(val methods: List) { @@ -306,7 +362,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( ), keyAliasOfSecureElement = Base64Url.encode(keyAliasOfSecureElement), deviceInformation = deviceInformation, - authenticationMethod = authenticationMethod.methods, + authenticationMethod = authenticationMethod.methods ) } @@ -319,9 +375,10 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( { algorithmHeaderValue = "ES256" setHeader("typ", "JWT") - payload = moshi.adapter(AuthenticationData::class.java).toJson(authenticationData) + payload = json.encodeToString(authenticationData) }, - privateKey, signature + privateKey, + signature ) fun buildEncryptedSignedAuthenticationData( diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt similarity index 73% rename from android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt index 2c0ceb1b..32e5d78d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt @@ -18,24 +18,30 @@ package de.gematik.ti.erp.app.idp.usecase -import android.net.Uri import de.gematik.ti.erp.app.BCProvider -import de.gematik.ti.erp.app.api.Result -import de.gematik.ti.erp.app.api.unwrap -import de.gematik.ti.erp.app.db.entities.IdpConfiguration import de.gematik.ti.erp.app.generateRandomAES256Key import de.gematik.ti.erp.app.idp.EllipticCurvesExtending import de.gematik.ti.erp.app.idp.api.IdpService import de.gematik.ti.erp.app.idp.api.REDIRECT_URI +import de.gematik.ti.erp.app.idp.api.models.IdpAuthFlowResult +import de.gematik.ti.erp.app.idp.api.models.IdpChallengeFlowResult +import de.gematik.ti.erp.app.idp.api.models.IdpInitialData +import de.gematik.ti.erp.app.idp.api.models.IdpNonce +import de.gematik.ti.erp.app.idp.api.models.IdpRefreshFlowResult +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.idp.api.models.IdpState +import de.gematik.ti.erp.app.idp.api.models.IdpTokenResult +import de.gematik.ti.erp.app.idp.api.models.IdpUnsignedChallenge import de.gematik.ti.erp.app.idp.api.models.JWSPublicKey import de.gematik.ti.erp.app.idp.api.models.TokenResponse import de.gematik.ti.erp.app.idp.buildJsonWebSignatureWithHealthCard import de.gematik.ti.erp.app.idp.buildKeyVerifier +import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository import de.gematik.ti.erp.app.secureRandomInstance import de.gematik.ti.erp.app.vau.usecase.TruststoreUseCase import de.gematik.ti.erp.app.vau.usecase.checkIdpCertificate +import java.net.URI import java.security.MessageDigest import java.security.PublicKey import java.security.Security @@ -43,8 +49,6 @@ import java.security.interfaces.ECPublicKey import java.time.Duration import java.time.Instant import javax.crypto.SecretKey -import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.jose4j.base64url.Base64 @@ -58,86 +62,15 @@ import org.jose4j.jwt.NumericDate import org.jose4j.jwt.consumer.JwtContext import org.jose4j.jwt.consumer.NumericDateValidator import org.jose4j.jwx.JsonWebStructure -import org.json.JSONObject -import timber.log.Timber +import io.github.aakira.napier.Napier +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long private val discoveryDocumentMaxValidityMinutes: Int = Duration.ofHours(24).toMinutes().toInt() private val discoveryDocumentMaxValiditySeconds: Int = Duration.ofHours(24).seconds.toInt() -enum class IdpScope { - Default, - BiometricPairing -} - -data class IdpChallengeFlowResult( - val scope: IdpScope, - val challenge: IdpUnsignedChallenge, -) - -data class IdpAuthFlowResult( - val accessToken: String, - val ssoToken: String, - val idTokenInsurantName: String, - val idTokenInsuranceIdentifier: String, - val idTokenInsuranceName: String -) - -data class IdpRefreshFlowResult( - val scope: IdpScope, - val accessToken: String -) - -data class IdpInitialData( - val config: IdpConfiguration, - val pukSigKey: JWSPublicKey, - val pukEncKey: JWSPublicKey, - val state: IdpState, - val nonce: IdpNonce, - val codeVerifier: String, - val codeChallenge: String, -) - -data class IdpUnsignedChallenge( - val signedChallenge: String, // raw jws - val challenge: String, // payload extracted from the jws - val expires: Long // expiry timestamp parsed from challenge -) - -data class IdpTokenResult( - val decryptedAccessToken: String, - val idTokenPayload: String, -) - -@JvmInline -value class IdpState(val state: String) { - operator fun component1(): String = state - - companion object { - fun create(outLength: Int = 32) = IdpState(generateRandomUrlSafeStringSecure(outLength)) - } -} - -@JvmInline -value class IdpNonce(val nonce: String) { - operator fun component1(): String = nonce - - companion object { - fun create() = IdpNonce( - generateRandomUrlSafeStringSecure(32) - ) - } -} - -internal fun generateRandomUrlSafeStringSecure(outLength: Int = 32): String { - require(outLength >= 1) - val chars = Base64Url.encode( - ByteArray((outLength / 4 + 1) * 3).apply { - secureRandomInstance().nextBytes(this) - } - ) - return chars.substring(0 until outLength) -} - // // Flow with health card: // (1) [initializeConfigurationAndKeys] -> [challengeFlow] -> [basicAuthFlow] -> result: [accessToken] & [singleSignOnToken] @@ -176,10 +109,8 @@ internal fun generateRandomUrlSafeStringSecure(outLength: Int = 32): String { // (*) result: [accessToken as JWS] & [ssoToken] // -@Singleton -class IdpBasicUseCase @Inject constructor( +class IdpBasicUseCase( private val repository: IdpRepository, - private val profilesRepository: ProfilesRepository, private val truststoreUseCase: TruststoreUseCase ) { @@ -200,7 +131,7 @@ class IdpBasicUseCase @Inject constructor( checkIdpConfigurationValidity(it, Instant.now()) } } catch (e: Exception) { - Timber.e(e, "IDP config couldn't be validated") + Napier.e("IDP config couldn't be validated", e) repository.invalidateConfig() // retry try { @@ -208,15 +139,15 @@ class IdpBasicUseCase @Inject constructor( checkIdpConfigurationValidity(it, Instant.now()) } } catch (e: Exception) { - Timber.e(e, "IDP config couldn't be validated again; finally aborting") + Napier.e("IDP config couldn't be validated again; finally aborting", e) repository.invalidateConfig() throw e } } // fetch both keys - val pukSigKey = repository.fetchIdpPukSig(config.pukIdpSigEndpoint).unwrap() - val pukEncKey = repository.fetchIdpPukEnc(config.pukIdpEncEndpoint).unwrap() + val pukSigKey = repository.fetchIdpPukSig(config.pukIdpSigEndpoint).getOrThrow() + val pukEncKey = repository.fetchIdpPukEnc(config.pukIdpEncEndpoint).getOrThrow() // check signature key with truststore val certPubKey = pukSigKey.jws.leafCertificate.publicKey as ECPublicKey @@ -245,6 +176,7 @@ class IdpBasicUseCase @Inject constructor( suspend fun challengeFlow( initialData: IdpInitialData, scope: IdpScope, + redirectUri: String ): IdpChallengeFlowResult { val (config, pukSigKey, _, state, nonce) = initialData val codeChallenge = initialData.codeChallenge @@ -257,7 +189,8 @@ class IdpBasicUseCase @Inject constructor( state = state, nonce = nonce, scope = scope, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) return IdpChallengeFlowResult( @@ -268,6 +201,7 @@ class IdpBasicUseCase @Inject constructor( suspend fun basicAuthFlow( initialData: IdpInitialData, + redirectUri: String = REDIRECT_URI, challengeData: IdpChallengeFlowResult, healthCardCertificate: ByteArray, sign: suspend (hash: ByteArray) -> ByteArray @@ -307,10 +241,11 @@ class IdpBasicUseCase @Inject constructor( codeVerifier = codeVerifier, code = redirectCodeJwe, pukEncKey = pukEncKey, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) - val idTokenJson = JSONObject( + val idTokenJson = Json.parseToJsonElement( idpTokenResult.idTokenPayload ) @@ -318,16 +253,19 @@ class IdpBasicUseCase @Inject constructor( return IdpAuthFlowResult( accessToken = idpTokenResult.decryptedAccessToken, ssoToken = redirectSsoToken, - idTokenInsuranceIdentifier = idTokenJson.getStringOrNull("idNummer") ?: "", - idTokenInsuranceName = idTokenJson.getStringOrNull("organizationName") ?: "", - idTokenInsurantName = idTokenJson.getStringOrNull("given_name")?.let { it + " " + idTokenJson.getString("family_name") } ?: "" + idTokenInsuranceIdentifier = idTokenJson.jsonObject["idNummer"]?.jsonPrimitive?.content ?: "", + idTokenInsuranceName = idTokenJson.jsonObject["organizationName"]?.jsonPrimitive?.content ?: "", + idTokenInsurantName = idTokenJson.jsonObject["given_name"]?.jsonPrimitive?.content?.let { + it + " " + idTokenJson.jsonObject["family_name"]?.jsonPrimitive?.content + } ?: "" ) } suspend fun refreshAccessTokenWithSsoFlow( initialData: IdpInitialData, scope: IdpScope, - ssoToken: String + ssoToken: String, + redirectUri: String ): IdpRefreshFlowResult { val (config, pukSigKey, pukEncKey) = initialData val state = initialData.state @@ -341,7 +279,8 @@ class IdpBasicUseCase @Inject constructor( state = state, nonce = nonce, scope = scope, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) val redirect = postUnsignedChallengeWithSsoTokenAndGetRedirect( @@ -360,7 +299,8 @@ class IdpBasicUseCase @Inject constructor( codeVerifier = codeVerifier, code = codeFromRedirect, pukEncKey = pukEncKey, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) return IdpRefreshFlowResult(scope, idpTokenResult.decryptedAccessToken) @@ -371,17 +311,9 @@ class IdpBasicUseCase @Inject constructor( suspend fun postSignedChallengeAndGetRedirect( url: String, codeChallenge: JsonWebEncryption, - state: IdpState, - ): Uri { - val redirect = when ( - val r = - repository.postSignedChallenge(url, codeChallenge.compactSerialization) - ) { - is Result.Success -> { - Uri.parse(r.data) - } - is Result.Error -> throw r.exception - } + state: IdpState + ): URI { + val redirect = URI(repository.postSignedChallenge(url, codeChallenge.compactSerialization).getOrThrow()) val redirectState = IdpService.extractQueryParameter(redirect, "state") require(state.state == redirectState) { "Invalid state" } @@ -393,17 +325,9 @@ class IdpBasicUseCase @Inject constructor( url: String, unsignedCodeChallenge: String, ssoToken: String, - state: IdpState, - ): Uri { - val redirect = when ( - val r = - repository.postUnsignedChallengeWithSso(url, ssoToken, unsignedCodeChallenge) - ) { - is Result.Success -> { - Uri.parse(r.data) - } - is Result.Error -> throw r.exception - } + state: IdpState + ): URI { + val redirect = URI(repository.postUnsignedChallengeWithSso(url, ssoToken, unsignedCodeChallenge).getOrThrow()) val redirectState = IdpService.extractQueryParameter(redirect, "state") require(state.state == redirectState) { "Invalid state" } @@ -417,33 +341,30 @@ class IdpBasicUseCase @Inject constructor( state: IdpState, nonce: IdpNonce, scope: IdpScope, - pukSigKey: JWSPublicKey + pukSigKey: JWSPublicKey, + redirectUri: String ): IdpUnsignedChallenge { - val signedChallenge = when ( - val r = repository.fetchChallenge( - url = url, - codeChallenge = codeChallenge, - state = state.state, - nonce = nonce.nonce, - isDeviceRegistration = scope == IdpScope.BiometricPairing - ) - ) { - is Result.Success -> { - r.data.challenge.jws.apply { - key = pukSigKey.jws.publicKey - } - r.data.challenge + val signedChallenge = repository.fetchChallenge( + url = url, + codeChallenge = codeChallenge, + state = state.state, + nonce = nonce.nonce, + isDeviceRegistration = scope == IdpScope.BiometricPairing, + redirectUri = redirectUri + ).map { + it.challenge.jws.apply { + key = pukSigKey.jws.publicKey } - is Result.Error -> throw r.exception - } + it.challenge + }.getOrThrow() // check state & nonce val unsignedChallenge = signedChallenge.jws.payload - val unsignedChallengeJson = JSONObject(unsignedChallenge) - require(state.state == unsignedChallengeJson["state"] as String) { "Invalid state" } - require(nonce.nonce == unsignedChallengeJson["nonce"] as String) { "Invalid nonce" } + val unsignedChallengeJson = Json.parseToJsonElement(unsignedChallenge) + require(state.state == unsignedChallengeJson.jsonObject["state"]!!.jsonPrimitive.content) { "Invalid state" } + require(nonce.nonce == unsignedChallengeJson.jsonObject["nonce"]!!.jsonPrimitive.content) { "Invalid nonce" } - val unsignedChallengeExpires = (unsignedChallengeJson["exp"] as Int).toLong() + val unsignedChallengeExpires = unsignedChallengeJson.jsonObject["exp"]!!.jsonPrimitive.long return IdpUnsignedChallenge( signedChallenge.raw, @@ -459,7 +380,7 @@ class IdpBasicUseCase @Inject constructor( code: String, pukEncKey: JWSPublicKey, pukSigKey: JWSPublicKey, - redirectUri: String = REDIRECT_URI + redirectUri: String ): IdpTokenResult { val symmetricalKey = generateRandomAES256Key() @@ -468,33 +389,27 @@ class IdpBasicUseCase @Inject constructor( codeVerifier, pukEncKey.jws.publicKey ) + return repository.postToken( + url = url, + keyVerifier = keyVerifier.compactSerialization, + code = code, + redirectUri = redirectUri + ).map { + val decryptedIdToken = decryptIdToken(it, symmetricalKey) + val idTokenPayload = decryptedIdToken.apply { + key = pukSigKey.jws.publicKey + }.payload + checkNonce( + idTokenPayload, + nonce.nonce + ) - return when ( - val r = repository.postToken( - url = url, - keyVerifier = keyVerifier.compactSerialization, - code = code, - redirectUri = redirectUri + val json = decryptAccessToken(it, symmetricalKey) + IdpTokenResult( + decryptedAccessToken = Json.parseToJsonElement(json).jsonObject["njwt"]!!.jsonPrimitive.content, + idTokenPayload = idTokenPayload ) - ) { - is Result.Success -> { - val decryptedIdToken = decryptIdToken(r.data, symmetricalKey) - val idTokenPayload = decryptedIdToken.apply { - key = pukSigKey.jws.publicKey - }.payload - checkNonce( - idTokenPayload, - nonce.nonce - ) - - val json = decryptAccessToken(r.data, symmetricalKey) - IdpTokenResult( - decryptedAccessToken = JSONObject(json)["njwt"] as String, - idTokenPayload = idTokenPayload - ) - } - is Result.Error -> throw r.exception - } + }.getOrThrow() } suspend fun buildSignedChallenge( @@ -545,7 +460,7 @@ class IdpBasicUseCase @Inject constructor( ) } - suspend fun checkIdpConfigurationValidity(config: IdpConfiguration, timestamp: Instant) { + suspend fun checkIdpConfigurationValidity(config: IdpData.IdpConfiguration, timestamp: Instant) { truststoreUseCase.checkIdpCertificate(config.certificate, true) val claims = JwtClaims().apply { @@ -570,7 +485,10 @@ class IdpBasicUseCase @Inject constructor( */ private fun decryptIdToken(data: TokenResponse, key: SecretKey): JsonWebSignature { val json = decryptJWE(data.idToken, key) - return JsonWebStructure.fromCompactSerialization(JSONObject(json).getString("njwt")) as JsonWebSignature + return JsonWebStructure.fromCompactSerialization( + Json.parseToJsonElement(json) + .jsonObject["njwt"]?.jsonPrimitive?.content + ) as JsonWebSignature } /** @@ -578,9 +496,9 @@ class IdpBasicUseCase @Inject constructor( */ private fun checkNonce(idTokenPayload: String, nonce: String) { require( - JSONObject( + Json.parseToJsonElement( idTokenPayload - ).getString("nonce") == nonce + ).jsonObject["nonce"]?.jsonPrimitive?.content == nonce ) } @@ -598,6 +516,3 @@ class IdpBasicUseCase @Inject constructor( private var cryptoInitializedLock = Mutex() } } - -fun JSONObject.getStringOrNull(name: String) = - if (has(name)) getString(name) else null diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt new file mode 100644 index 00000000..0087d0be --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +import java.security.KeyStore +import java.security.Signature + +expect class IdpCryptoProvider { + fun keyStoreInstance(): KeyStore + fun signatureInstance(algorithm: String = "SHA256withECDSA"): Signature +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt new file mode 100644 index 00000000..af939293 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +expect class IdpDeviceInfoProvider { + val deviceName: String + val manufacturer: String + val productName: String + val model: String + val operatingSystem: String + val operatingSystemVersion: String +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt new file mode 100644 index 00000000..55eb6f50 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +expect class IdpPreferenceProvider { + var externalAuthenticationPreferences: ExternalAuthenticationPreferences +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt new file mode 100644 index 00000000..60f5cc6d --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt @@ -0,0 +1,637 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +import de.gematik.ti.erp.app.api.ApiCallException +import de.gematik.ti.erp.app.idp.api.EXT_AUTH_REDIRECT_URI +import de.gematik.ti.erp.app.idp.api.IdpService +import de.gematik.ti.erp.app.idp.api.REDIRECT_URI +import de.gematik.ti.erp.app.idp.api.models.AuthenticationId +import de.gematik.ti.erp.app.idp.api.models.ExternalAuthorizationData +import de.gematik.ti.erp.app.idp.api.models.IdpAuthFlowResult +import de.gematik.ti.erp.app.idp.api.models.IdpInitialData +import de.gematik.ti.erp.app.idp.api.models.IdpNonce +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.idp.api.models.PairingData +import de.gematik.ti.erp.app.idp.api.models.PairingResponseEntry +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.idp.repository.IdpPairingRepository +import de.gematik.ti.erp.app.idp.repository.IdpRepository +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository +import de.gematik.ti.erp.app.vau.extractECPublicKey +import java.io.IOException +import java.net.URI +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.bouncycastle.util.encoders.Base64 +import io.github.aakira.napier.Napier +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.HttpURLConnection + +/** + * Exception thrown by [IdpUseCase.loadAccessToken]. + */ +class RefreshFlowException : IOException { + /** + * Is true if the sso token is not valid anymore and the user is required to authenticate again. + */ + val userActionRequired: Boolean + val ssoToken: IdpData.SingleSignOnTokenScope? + + constructor( + userActionRequired: Boolean, + ssoToken: IdpData.SingleSignOnTokenScope?, + cause: Throwable + ) : super(cause) { + this.userActionRequired = userActionRequired + this.ssoToken = ssoToken + } + + constructor( + userActionRequired: Boolean, + ssoToken: IdpData.SingleSignOnTokenScope?, + message: String + ) : super(message) { + this.userActionRequired = userActionRequired + this.ssoToken = ssoToken + } +} + +class IDPConfigException(cause: Throwable) : IOException(cause) + +class AltAuthenticationCryptoException(cause: Throwable) : IllegalStateException(cause) + +class IdpUseCase( + private val repository: IdpRepository, + private val pairingRepository: IdpPairingRepository, + private val altAuthUseCase: IdpAlternateAuthenticationUseCase, + private val profilesRepository: ProfilesRepository, + private val basicUseCase: IdpBasicUseCase, + private val preferences: IdpPreferenceProvider, + private val cryptoProvider: IdpCryptoProvider +) { + private val lock = Mutex() + + /** + * If no bearer token is set or [refresh] is true, this will trigger [IdpBasicUseCase.refreshAccessTokenWithSsoFlow]. + */ + suspend fun loadAccessToken( + refresh: Boolean = false, + profileId: ProfileIdentifier, + scope: IdpScope = IdpScope.Default + ): String = lock.withLock { + when (scope) { + IdpScope.Default -> + loadAccessToken( + refresh = refresh, + profileId = profileId, + scope = IdpScope.Default, + singleSignOnTokenScope = { + repository.authenticationData(profileId).first().singleSignOnTokenScope + }, + decryptedAccessToken = { repository.decryptedAccessToken(profileId).first() }, + invalidateDecryptedAccessToken = { repository.invalidateDecryptedAccessToken(profileId) }, + invalidateSingleSignOnTokenRetainingScope = { + repository.invalidateSingleSignOnTokenRetainingScope( + profileId + ) + }, + saveDecryptedAccessToken = { repository.saveDecryptedAccessToken(profileId, it) } + ) + IdpScope.BiometricPairing -> + loadAccessToken( + refresh = refresh, + profileId = profileId, + scope = IdpScope.BiometricPairing, + singleSignOnTokenScope = { pairingRepository.singleSignOnTokenScope(profileId).first() }, + decryptedAccessToken = { pairingRepository.decryptedAccessToken(profileId).first() }, + invalidateDecryptedAccessToken = { pairingRepository.invalidateDecryptedAccessToken(profileId) }, + invalidateSingleSignOnTokenRetainingScope = { + pairingRepository.invalidateSingleSignOnToken( + profileId + ) + }, + saveDecryptedAccessToken = { pairingRepository.saveDecryptedAccessToken(profileId, it) } + ) + } + } + + private suspend fun loadAccessToken( + refresh: Boolean = false, + profileId: ProfileIdentifier, + scope: IdpScope, + singleSignOnTokenScope: suspend () -> IdpData.SingleSignOnTokenScope?, + decryptedAccessToken: suspend () -> String?, + invalidateDecryptedAccessToken: suspend () -> Unit, + invalidateSingleSignOnTokenRetainingScope: suspend () -> Unit, + saveDecryptedAccessToken: suspend (decryptedAccessToken: String) -> Unit + ): String { + val ssoTokenScope = singleSignOnTokenScope() + + Napier.d { + """Loading access token with: + |refresh: $refresh + |profileId: $profileId + |scope: $scope + """.trimMargin() + } + + return if (ssoTokenScope != null) { + if (ssoTokenScope.token?.token == null) { + invalidateDecryptedAccessToken() + throw RefreshFlowException( + true, + ssoTokenScope, + "SSO token not set for $profileId!" + ) + } + + val accToken = decryptedAccessToken() + + if (refresh || accToken == null) { + invalidateDecryptedAccessToken() + + val actualToken = ssoTokenScope.token!!.token + + val initialData = try { + basicUseCase.initializeConfigurationAndKeys() + } catch (e: Exception) { + throw IDPConfigException(e) + } + try { + val refreshData = basicUseCase.refreshAccessTokenWithSsoFlow( + initialData, + scope = scope, + ssoToken = actualToken, + redirectUri = if (ssoTokenScope is IdpData.ExternalAuthenticationToken) { + EXT_AUTH_REDIRECT_URI + } else { + REDIRECT_URI + } + ) + refreshData.accessToken + } catch (e: Exception) { + Napier.e("Couldn't refresh access token", e) + (e as? ApiCallException)?.also { + when (it.response.code()) { + // 400 returned by redirect call if sso token is not valid anymore + 400, 401, 403 -> { + invalidateSingleSignOnTokenRetainingScope() + throw RefreshFlowException(true, ssoTokenScope, e) + } + } + } + throw RefreshFlowException(false, null, e) + } + } else { + accToken + } + .also { + saveDecryptedAccessToken(it) + } + } else { + invalidateDecryptedAccessToken() + throw RefreshFlowException( + true, + ssoTokenScope, + "SSO token not set for $profileId!" + ) + } + } + + /** + * Initial flow fetching the sso & access token requiring the health card to sign the challenge. + */ + suspend fun authenticationFlowWithHealthCard( + profileId: ProfileIdentifier, + scope: IdpScope = IdpScope.Default, + cardAccessNumber: String, + healthCardCertificate: suspend () -> ByteArray, + sign: suspend (hash: ByteArray) -> ByteArray + ) { + lock.withLock { + authenticationFlowWithHealthCard( + cardAccessNumber = cardAccessNumber, + scope = scope, + healthCardCertificate = healthCardCertificate, + sign = sign + ) { _, _, basicData, ssoToken -> + when (scope) { + IdpScope.Default -> { + profilesRepository.saveInsuranceInformation( + profileId, + basicData.idTokenInsurantName, + basicData.idTokenInsuranceIdentifier, + basicData.idTokenInsuranceName + ) + repository.saveSingleSignOnToken(profileId, ssoToken) + repository.saveDecryptedAccessToken(profileId, basicData.accessToken) + } + IdpScope.BiometricPairing -> { + pairingRepository.saveSingleSignOnToken( + profileId, + IdpData.SingleSignOnToken(basicData.ssoToken) + ) + } + } + } + } + } + + private suspend fun authenticationFlowWithHealthCard( + cardAccessNumber: String, + scope: IdpScope, + healthCardCertificate: suspend () -> ByteArray, + sign: suspend (hash: ByteArray) -> ByteArray, + finally: suspend ( + initialData: IdpInitialData, + healthCardCertificate: ByteArray, + basicData: IdpAuthFlowResult, + ssoToken: IdpData.DefaultToken + ) -> R + ): R { + val initialData = basicUseCase.initializeConfigurationAndKeys() + val challengeData = + basicUseCase.challengeFlow(initialData, scope = scope, redirectUri = REDIRECT_URI) + val cert = healthCardCertificate() + val basicData = basicUseCase.basicAuthFlow( + initialData = initialData, + challengeData = challengeData, + healthCardCertificate = cert, + sign = sign + ) + val ssoToken = IdpData.DefaultToken( + token = IdpData.SingleSignOnToken(basicData.ssoToken), + healthCardCertificate = cert, + cardAccessNumber = cardAccessNumber + ) + + return finally( + initialData, + cert, + basicData, + ssoToken + ) + } + + /** + * Get all the information for the correct endpoints from the discovery document and request + * the external Health Insurance Companies which are capable of authenticate you with their app + */ + suspend fun loadExternAuthenticatorIDs(): List { + val initialData = basicUseCase.initializeConfigurationAndKeys() + return repository.fetchExternalAuthorizationIDList( + url = initialData.config.externalAuthorizationIDsEndpoint ?: error("Fasttrack is not available"), + idpPukSigKey = initialData.config.certificate.extractECPublicKey() + ).sortedBy { + it.name.lowercase() + } + } + + /** + * With chosen Health Insurance Company, request IDP for Authentication information, + * sent as a redirect which is supposed to be fired as an Intent + * @param externalAuthorizationId identifier of the health insurance company + */ + suspend fun getUniversalLinkForExternalAuthorization( + profileId: ProfileIdentifier, + authenticatorId: String, + authenticatorName: String, + scope: IdpScope = IdpScope.Default + ): URI { + val initialData = basicUseCase.initializeConfigurationAndKeys() + + val redirectUri = repository.getAuthorizationRedirect( + url = initialData.config.thirdPartyAuthorizationEndpoint ?: error("Fasttrack is not available"), + state = initialData.state, + codeChallenge = initialData.codeChallenge, + nonce = initialData.nonce, + kkAppId = authenticatorId, + scope = scope + ) + + val parsedUri = URI(redirectUri) + + preferences.externalAuthenticationPreferences = + ExternalAuthenticationPreferences( + extAuthCodeChallenge = initialData.codeChallenge, + extAuthCodeVerifier = initialData.codeVerifier, + extAuthState = IdpService.extractQueryParameter(parsedUri, "state"), + extAuthNonce = initialData.nonce.nonce, + extAuthId = authenticatorId, + extAuthScope = scope.name, + extAuthName = authenticatorName, + extAuthProfile = profileId + ) + + return parsedUri + } + + /** + * The scope is determined by the previously saved value within the shared prefs as `EXT_AUTH_SCOPE`. + */ + suspend fun authenticateWithExternalAppAuthorization( + uri: URI + ) { + lock.withLock { + val scope = preferences.externalAuthenticationPreferences.extAuthScope!! + val profileId = preferences.externalAuthenticationPreferences.extAuthProfile!! + + val externalAuthorizationData = ExternalAuthorizationData(uri) + + require(externalAuthorizationData.state == preferences.externalAuthenticationPreferences.extAuthState) + + val initialData = basicUseCase.initializeConfigurationAndKeys() + val redirectStringResult = repository.postExternAppAuthorizationData( + url = initialData.config.thirdPartyAuthorizationEndpoint ?: error("Fasttrack is not available"), + externalAuthorizationData = externalAuthorizationData + ) + val redirect = URI(redirectStringResult.getOrThrow()) + + val redirectCodeJwe = IdpService.extractQueryParameter(redirect, "code") + val redirectSsoToken = IdpService.extractQueryParameter(redirect, "ssotoken") + + val idpTokenResult = basicUseCase.postCodeAndDecryptAccessToken( + url = initialData.config.tokenEndpoint, + nonce = IdpNonce(preferences.externalAuthenticationPreferences.extAuthNonce!!), + codeVerifier = preferences.externalAuthenticationPreferences.extAuthCodeVerifier!!, + code = redirectCodeJwe, + pukEncKey = initialData.pukEncKey, + pukSigKey = initialData.pukSigKey, + redirectUri = EXT_AUTH_REDIRECT_URI + ) + + val authId = preferences.externalAuthenticationPreferences.extAuthId!! + val authName = preferences.externalAuthenticationPreferences.extAuthName!! + + preferences.clear() + + when (scope) { + IdpScope.Default.name -> { + val idTokenJson = Json.parseToJsonElement(idpTokenResult.idTokenPayload) + + val idTokenInsuranceIdentifier = idTokenJson.jsonObject["idNummer"]?.jsonPrimitive?.content ?: "" + val idTokenInsuranceName = idTokenJson.jsonObject["organizationName"]?.jsonPrimitive?.content ?: "" + val idTokenInsurantName = idTokenJson.jsonObject["given_name"]?.jsonPrimitive?.content + ?.let { + "$it ${idTokenJson.jsonObject["family_name"]?.jsonPrimitive?.content}" + } ?: "" + + profilesRepository.saveInsuranceInformation( + profileId = profileId, + insurantName = idTokenInsurantName, + insuranceIdentifier = idTokenInsuranceIdentifier, + insuranceName = idTokenInsuranceName + ) + + repository.saveSingleSignOnToken( + profileId, + IdpData.ExternalAuthenticationToken( + token = IdpData.SingleSignOnToken(redirectSsoToken), + authenticatorId = authId, + authenticatorName = authName + ) + ) + repository.saveDecryptedAccessToken(profileId, idpTokenResult.decryptedAccessToken) + } + IdpScope.BiometricPairing.name -> { + pairingRepository.saveSingleSignOnToken( + profileId, + IdpData.SingleSignOnToken(redirectSsoToken) + ) + } + } + } + } + + /** + * Pairing flow fetching the sso & access token requiring the health card and generated key material. + */ + suspend fun alternatePairingFlowWithSecureElement( + profileId: ProfileIdentifier, + cardAccessNumber: String, + publicKeyOfSecureElementEntry: PublicKey, + aliasOfSecureElementEntry: ByteArray, + healthCardCertificate: suspend () -> ByteArray, + signWithHealthCard: suspend (hash: ByteArray) -> ByteArray + ) = lock.withLock { + val initialData = basicUseCase.initializeConfigurationAndKeys() + val challengeData = + basicUseCase.challengeFlow( + initialData, + scope = IdpScope.BiometricPairing, + redirectUri = REDIRECT_URI + ) + val healthCardCert = healthCardCertificate() + val basicData = basicUseCase.basicAuthFlow( + initialData = initialData, + challengeData = challengeData, + healthCardCertificate = healthCardCert, + sign = signWithHealthCard + ) + + altAuthUseCase.registerDeviceWithHealthCard( + initialData = initialData, + accessToken = basicData.accessToken, + healthCardCertificate = healthCardCert, + publicKeyOfSecureElementEntry = publicKeyOfSecureElementEntry, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + signWithHealthCard = signWithHealthCard + ) + profilesRepository.saveInsuranceInformation( + profileId, + basicData.idTokenInsurantName, + basicData.idTokenInsuranceIdentifier, + basicData.idTokenInsuranceName + ) + // set pairing scope + repository.saveSingleSignOnToken( + profileId, + IdpData.AlternateAuthenticationWithoutToken( + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + healthCardCertificate = healthCardCert + ) + ) + } + + /** + * Actual authentication with secure element key material. Just like the [authenticationFlowWithHealthCard] it + * sets the sso & access token within the repository. + */ + suspend fun alternateAuthenticationFlowWithSecureElement( + profileId: ProfileIdentifier, + scope: IdpScope = IdpScope.Default + ) { + lock.withLock { + alternateAuthenticationFlowWithSecureElement( + profileId = profileId, + scope = IdpScope.Default + ) { _, authTokenScope, authData -> + when (scope) { + IdpScope.Default -> { + profilesRepository.saveInsuranceInformation( + profileId, + authData.idTokenInsurantName, + authData.idTokenInsuranceIdentifier, + authData.idTokenInsuranceName + ) + repository.saveSingleSignOnToken( + profileId, + IdpData.AlternateAuthenticationToken( + IdpData.SingleSignOnToken(authData.ssoToken), + cardAccessNumber = authTokenScope.cardAccessNumber, + aliasOfSecureElementEntry = authTokenScope.aliasOfSecureElementEntry, + healthCardCertificate = authTokenScope.healthCardCertificate.encoded + ) + ) + repository.saveDecryptedAccessToken(profileId, authData.accessToken) + } + IdpScope.BiometricPairing -> { + pairingRepository.saveSingleSignOnToken( + profileId, + IdpData.SingleSignOnToken(authData.ssoToken) + ) + } + } + } + } + } + + private suspend fun alternateAuthenticationFlowWithSecureElement( + profileId: ProfileIdentifier, + scope: IdpScope, + finally: suspend ( + initialData: IdpInitialData, + authTokenScope: IdpData.TokenWithKeyStoreAliasScope, + authData: IdpAuthFlowResult + ) -> R + ): R { + val ssoTokenScope = requireNotNull(repository.authenticationData(profileId).first().singleSignOnTokenScope) + + val authTokenScope = + requireNotNull(ssoTokenScope as? IdpData.TokenWithKeyStoreAliasScope) { "Wrong authentication scope!" } + + val healthCardCertificate = authTokenScope.healthCardCertificate + val aliasOfSecureElementEntry = authTokenScope.aliasOfSecureElementEntry + + lateinit var privateKeyOfSecureElementEntry: PrivateKey + lateinit var signatureObjectOfSecureElementEntry: Signature + + try { + privateKeyOfSecureElementEntry = ( + cryptoProvider.keyStoreInstance() + .apply { load(null) } + .getEntry( + Base64.toBase64String(aliasOfSecureElementEntry), + null + ) as KeyStore.PrivateKeyEntry + ).privateKey + signatureObjectOfSecureElementEntry = cryptoProvider.signatureInstance() + } catch (e: Exception) { + // the system might have removed the key during biometric re-enrollment + // therefore there's no choice but to delete everything + repository.invalidate(profileId) + throw AltAuthenticationCryptoException(e) + } + + val initialData = basicUseCase.initializeConfigurationAndKeys() + val challengeData = basicUseCase.challengeFlow(initialData, scope = scope, redirectUri = REDIRECT_URI) + + val authData = altAuthUseCase.authenticateWithSecureElement( + initialData = initialData, + challenge = challengeData.challenge, + healthCardCertificate = healthCardCertificate.encoded, + authenticationMethod = IdpAlternateAuthenticationUseCase.AuthenticationMethod.Strong, + aliasOfSecureElementEntry = aliasOfSecureElementEntry, + privateKeyOfSecureElementEntry = privateKeyOfSecureElementEntry, + signatureObjectOfSecureElementEntry = signatureObjectOfSecureElementEntry + ) + + return finally( + initialData, + authTokenScope, + authData + ) + } + + /** + * Returns the paired devices associated with the [profileId]s sso token scope. + * + * @param authenticateWithSecureElement will be called if an alternate authentication is required. + * @param authenticateWithHealthCard will be called if a health card authentication is required + * which needs to sign [hash]. + */ + suspend fun getPairedDevices(profileId: ProfileIdentifier): Result>> = + redoOnce { + val accessToken = loadAccessToken( + refresh = it, + profileId = profileId, + scope = IdpScope.BiometricPairing + ) + + altAuthUseCase.getPairedDevices( + initialData = basicUseCase.initializeConfigurationAndKeys(), + accessToken = accessToken + ) + } + + /** + * Deletes the device identified by [deviceAlias]. + */ + suspend fun deletePairedDevice(profileId: ProfileIdentifier, deviceAlias: String) = + redoOnce { + val accessToken = loadAccessToken( + refresh = it, + profileId = profileId, + scope = IdpScope.BiometricPairing + ) + + altAuthUseCase.deletePairedDevice( + initialData = basicUseCase.initializeConfigurationAndKeys(), + accessToken = accessToken, + deviceAlias = deviceAlias + ) + } + + private suspend fun redoOnce( + block: suspend (retry: Boolean) -> R + ) = + runCatching { + block(false) + }.recoverCatching { e -> + val isRetryable = (e as? ApiCallException)?.let { + it.response.code() == HttpURLConnection.HTTP_FORBIDDEN || + it.response.code() == HttpURLConnection.HTTP_UNAUTHORIZED + } ?: false + if (isRetryable) { + block(true) + } else { + throw e + } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/model/ProfilesData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/model/ProfilesData.kt new file mode 100644 index 00000000..3670a950 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/model/ProfilesData.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.model + +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import java.time.Instant + +object ProfilesData { + enum class AvatarFigure { + PersonalizedImage, + FemaleDoctor, + WomanWithHeadScarf, + Grandfather, + BoyWithHealthCard, + OldManOfColor, + WomanWithPhone, + Grandmother, + ManWithPhone, + WheelchairUser, + Baby, + MaleDoctorWithPhone, + FemaleDoctorWithPhone, + FemaleDeveloper + } + class Profile( + val id: ProfileIdentifier, + val color: ProfileColorNames, + val avatarFigure: AvatarFigure, + val personalizedImage: ByteArray? = null, + val name: String, + val insurantName: String? = null, + val insuranceIdentifier: String? = null, + val insuranceName: String? = null, + val lastAuthenticated: Instant? = null, + val lastAuditEventSynced: Instant? = null, + val lastTaskSynced: Instant? = null, + val active: Boolean = false, + val singleSignOnTokenScope: IdpData.SingleSignOnTokenScope? + ) { + override fun toString(): String { + return "Profile(id='$id', color=$color, name='$name', insurantName=$insurantName, insuranceIdentifier=$insuranceIdentifier, insuranceName=$insuranceName, lastAuthenticated=$lastAuthenticated, lastAuditEventSynced=$lastAuditEventSynced, lastTaskSynced=$lastTaskSynced, active=$active, singleSignOnTokenScope=$singleSignOnTokenScope)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Profile + + if (id != other.id) return false + if (avatarFigure != other.avatarFigure) return false + if (personalizedImage != null) { + if (other.personalizedImage == null) return false + if (!personalizedImage.contentEquals(other.personalizedImage)) return false + } else if (other.personalizedImage != null) return false + if (name != other.name) return false + if (insurantName != other.insurantName) return false + if (insuranceIdentifier != other.insuranceIdentifier) return false + if (insuranceName != other.insuranceName) return false + if (lastAuthenticated != other.lastAuthenticated) return false + if (lastAuditEventSynced != other.lastAuditEventSynced) return false + if (lastTaskSynced != other.lastTaskSynced) return false + if (singleSignOnTokenScope != other.singleSignOnTokenScope) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + color.hashCode() + result = 31 * result + avatarFigure.hashCode() + result = 31 * result + (personalizedImage?.contentHashCode() ?: 0) + result = 31 * result + name.hashCode() + result = 31 * result + (insurantName?.hashCode() ?: 0) + result = 31 * result + (insuranceIdentifier?.hashCode() ?: 0) + result = 31 * result + (insuranceName?.hashCode() ?: 0) + result = 31 * result + (lastAuthenticated?.hashCode() ?: 0) + result = 31 * result + (lastAuditEventSynced?.hashCode() ?: 0) + result = 31 * result + (lastTaskSynced?.hashCode() ?: 0) + result = 31 * result + active.hashCode() + result = 31 * result + (singleSignOnTokenScope?.hashCode() ?: 0) + return result + } + } + + enum class ProfileColorNames { + SPRING_GRAY, + SUN_DEW, + PINK, + TREE, + BLUE_MOON + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt new file mode 100644 index 00000000..329d4814 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.db.entities.deleteAll +import de.gematik.ti.erp.app.db.entities.v1.AvatarFigureV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileColorNamesV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.idp.repository.toSingleSignOnTokenScope +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Instant + +typealias ProfileIdentifier = String + +class KVNRAlreadyAssignedException( + message: String, + val isActiveProfile: Boolean, + val inProfile: String, + val insuranceIdentifier: String +) : IllegalStateException(message) + +class ProfilesRepository constructor( + private val dispatchers: DispatchProvider, + private val realm: Realm +) { + private val lock = Mutex() + + fun profiles() = + realm.query().asFlow().mapNotNull { + val hasActiveProfile = it.list.any { profile -> profile.active } + + it.list.mapIndexed { index, profile -> + ProfilesData.Profile( + id = profile.id, + color = when (profile.color) { + ProfileColorNamesV1.SPRING_GRAY -> ProfilesData.ProfileColorNames.SPRING_GRAY + ProfileColorNamesV1.SUN_DEW -> ProfilesData.ProfileColorNames.SUN_DEW + ProfileColorNamesV1.PINK -> ProfilesData.ProfileColorNames.PINK + ProfileColorNamesV1.TREE -> ProfilesData.ProfileColorNames.TREE + ProfileColorNamesV1.BLUE_MOON -> ProfilesData.ProfileColorNames.BLUE_MOON + }, + avatarFigure = when (profile.avatarFigure) { + AvatarFigureV1.PersonalizedImage -> ProfilesData.AvatarFigure.PersonalizedImage + AvatarFigureV1.FemaleDoctor -> ProfilesData.AvatarFigure.FemaleDoctor + AvatarFigureV1.WomanWithHeadScarf -> ProfilesData.AvatarFigure.WomanWithHeadScarf + AvatarFigureV1.Grandfather -> ProfilesData.AvatarFigure.Grandfather + AvatarFigureV1.BoyWithHealthCard -> ProfilesData.AvatarFigure.BoyWithHealthCard + AvatarFigureV1.OldManOfColor -> ProfilesData.AvatarFigure.OldManOfColor + AvatarFigureV1.WomanWithPhone -> ProfilesData.AvatarFigure.WomanWithPhone + AvatarFigureV1.Grandmother -> ProfilesData.AvatarFigure.Grandmother + AvatarFigureV1.ManWithPhone -> ProfilesData.AvatarFigure.ManWithPhone + AvatarFigureV1.WheelchairUser -> ProfilesData.AvatarFigure.WheelchairUser + AvatarFigureV1.Baby -> ProfilesData.AvatarFigure.Baby + AvatarFigureV1.MaleDoctorWithPhone -> ProfilesData.AvatarFigure.MaleDoctorWithPhone + AvatarFigureV1.FemaleDoctorWithPhone -> ProfilesData.AvatarFigure.FemaleDoctorWithPhone + AvatarFigureV1.FemaleDeveloper -> ProfilesData.AvatarFigure.FemaleDeveloper + }, + personalizedImage = profile.personalizedImage, + name = profile.name, + insurantName = profile.insurantName ?: "", + insuranceIdentifier = profile.insuranceIdentifier, + insuranceName = profile.insuranceName, + lastAuthenticated = profile.lastAuthenticated?.toInstant(), + lastAuditEventSynced = profile.lastAuditEventSynced?.toInstant(), + lastTaskSynced = profile.lastTaskSynced?.toInstant(), + // TODO change architecture of active profile + active = if (!hasActiveProfile && index == 0) { + true + } else { + profile.active + }, + singleSignOnTokenScope = profile.idpAuthenticationData?.toSingleSignOnTokenScope() + + ) + } + }.flowOn(dispatchers.Main) + + suspend fun saveProfile(profileName: String, activate: Boolean) { + realm.write { + if (activate) { + query().find().forEach { + it.active = false + } + } + copyToRealm( + ProfileEntityV1().apply { + this.name = profileName + this.active = activate + this.color = ProfileColorNamesV1.values().random() + } + ) + } + } + + // tag::SwitchActiveProfileRepository[] + suspend fun activateProfile(profileId: ProfileIdentifier) { + realm.write { + query("id != $0", profileId).find().forEach { + it.active = false + } + query("id = $0", profileId).first().find()?.apply { + this.active = true + } + } + } + // end::SwitchActiveProfileRepository[] + + suspend fun removeProfile(profileId: ProfileIdentifier) { + lock.withLock { + realm.writeBlocking { + val profiles = query().find() + + if (profiles.size == 1) { + error("Can't remove the last profile!") + } + + queryFirst("id = $0", profileId)?.let { profileToDelete -> + if (profileToDelete.active) { + findLatest(profiles.query("id != $0", profileId).first())?.active = true + } + + deleteAll(profileToDelete) + } + } + } + } + + suspend fun saveInsuranceInformation( + profileId: ProfileIdentifier, + insurantName: String, + insuranceIdentifier: String, + insuranceName: String + ) { + lock.withLock { + realm.queryFirst("insuranceIdentifier == $0 AND id != $1", insuranceIdentifier, profileId) + ?.let { + throw KVNRAlreadyAssignedException( + "KVNR already assigned to another profile", + false, + it.name, + it.insuranceIdentifier!! + ) + } + + realm.queryFirst( + "insuranceIdentifier != NULL && insuranceIdentifier != $0 AND id == $1", + insuranceIdentifier, + profileId + ) + ?.let { + throw KVNRAlreadyAssignedException( + "Profile already assigned to another KVNR", + true, + profileId, + it.insuranceIdentifier!! + ) + } + + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.insuranceName = insuranceName + this.insuranceIdentifier = insuranceIdentifier + this.insurantName = insurantName + } + } + } + } + + suspend fun updateProfileName(profileId: ProfileIdentifier, profileName: String) { + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.name = profileName + } + } + } + + suspend fun updateProfileColor(profileId: ProfileIdentifier, color: ProfilesData.ProfileColorNames) { + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.color = when (color) { + ProfilesData.ProfileColorNames.SPRING_GRAY -> ProfileColorNamesV1.SPRING_GRAY + ProfilesData.ProfileColorNames.SUN_DEW -> ProfileColorNamesV1.SUN_DEW + ProfilesData.ProfileColorNames.PINK -> ProfileColorNamesV1.PINK + ProfilesData.ProfileColorNames.TREE -> ProfileColorNamesV1.TREE + ProfilesData.ProfileColorNames.BLUE_MOON -> ProfileColorNamesV1.BLUE_MOON + } + } + } + } + + suspend fun updateLastAuthenticated(profileId: ProfileIdentifier, lastAuthenticated: Instant) { + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.lastAuthenticated = lastAuthenticated.toRealmInstant() + } + } + } + + suspend fun saveAvatarFigure(profileId: ProfileIdentifier, avatarFigure: ProfilesData.AvatarFigure) { + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.avatarFigure = when (avatarFigure) { + ProfilesData.AvatarFigure.PersonalizedImage -> AvatarFigureV1.PersonalizedImage + ProfilesData.AvatarFigure.FemaleDoctor -> AvatarFigureV1.FemaleDoctor + ProfilesData.AvatarFigure.WomanWithHeadScarf -> AvatarFigureV1.WomanWithHeadScarf + ProfilesData.AvatarFigure.Grandfather -> AvatarFigureV1.Grandfather + ProfilesData.AvatarFigure.BoyWithHealthCard -> AvatarFigureV1.BoyWithHealthCard + ProfilesData.AvatarFigure.OldManOfColor -> AvatarFigureV1.OldManOfColor + ProfilesData.AvatarFigure.WomanWithPhone -> AvatarFigureV1.WomanWithPhone + ProfilesData.AvatarFigure.Grandmother -> AvatarFigureV1.Grandmother + ProfilesData.AvatarFigure.ManWithPhone -> AvatarFigureV1.ManWithPhone + ProfilesData.AvatarFigure.WheelchairUser -> AvatarFigureV1.WheelchairUser + ProfilesData.AvatarFigure.Baby -> AvatarFigureV1.Baby + ProfilesData.AvatarFigure.MaleDoctorWithPhone -> AvatarFigureV1.MaleDoctorWithPhone + ProfilesData.AvatarFigure.FemaleDoctorWithPhone -> AvatarFigureV1.FemaleDoctorWithPhone + ProfilesData.AvatarFigure.FemaleDeveloper -> AvatarFigureV1.FemaleDeveloper + } + } + } + } + + suspend fun savePersonalizedProfileImage(profileId: ProfileIdentifier, profileImage: ByteArray) { + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.personalizedImage = profileImage + } + } + } + + suspend fun clearPersonalizedProfileImage(profileId: ProfileIdentifier) { + realm.write { + queryFirst("id = $0", profileId)?.apply { + this.personalizedImage = null + this.avatarFigure = AvatarFigureV1.PersonalizedImage + } + } + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/ProtocolModule.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/ProtocolModule.kt new file mode 100644 index 00000000..447990a7 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/ProtocolModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.protocol + +import de.gematik.ti.erp.app.protocol.repository.AuditEventLocalDataSource +import de.gematik.ti.erp.app.protocol.repository.AuditEventRemoteDataSource +import de.gematik.ti.erp.app.protocol.repository.AuditEventsRepository +import org.kodein.di.DI +import org.kodein.di.bindProvider +import org.kodein.di.instance + +val protocolModule = DI.Module("protocolModule") { + bindProvider { AuditEventsRepository(instance(), instance(), instance()) } + bindProvider { AuditEventLocalDataSource(instance()) } + bindProvider { AuditEventRemoteDataSource(instance()) } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/model/AuditEventData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/model/AuditEventData.kt new file mode 100644 index 00000000..5a2e1bf5 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/model/AuditEventData.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.protocol.model + +import java.time.Instant + +object AuditEventData { + data class AuditEvent( + val auditId: String, + val medicationText: String?, + val description: String, + val timestamp: Instant + ) +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventsRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventsRepository.kt new file mode 100644 index 00000000..a17ce9ba --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventsRepository.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.protocol.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.api.ResourcePaging +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import java.time.Instant + +private const val AuditEventsMaxPageSize = 50 + +class AuditEventsRepository( + private val remoteDataSource: AuditEventRemoteDataSource, + private val localDataSource: AuditEventLocalDataSource, + private val dispatchers: DispatchProvider +) : ResourcePaging(dispatchers, AuditEventsMaxPageSize) { + + suspend fun downloadAuditEvents(profileId: ProfileIdentifier) = downloadPaged(profileId) + + fun auditEvents(profileId: ProfileIdentifier) = localDataSource.auditEvents(profileId).flowOn(dispatchers.IO) + + override suspend fun downloadResource( + profileId: ProfileIdentifier, + timestamp: String?, + count: Int? + ): Result = + remoteDataSource.getAuditEvents( + profileId = profileId, + lastKnownUpdate = timestamp, + count = count + ).mapCatching { fhirBundle -> + withContext(dispatchers.IO) { + localDataSource.saveAuditEvents(profileId, fhirBundle) + } + } + + override suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? = + localDataSource.latestAuditEventTimestamp(profileId).first() +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/LocalDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/LocalDataSource.kt new file mode 100644 index 00000000..e884799d --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/LocalDataSource.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.protocol.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import androidx.paging.PagingState +import de.gematik.ti.erp.app.db.entities.v1.AuditEventEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.db.tryWrite +import de.gematik.ti.erp.app.fhir.model.extractAuditEvents +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import de.gematik.ti.erp.app.protocol.model.AuditEventData +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import io.realm.kotlin.query.max +import io.realm.kotlin.types.RealmInstant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.JsonElement +import kotlin.math.max +import kotlin.math.min + +// max page size within ui +private const val AuditEventsMaxPageSize = 25 + +class AuditEventLocalDataSource( + private val realm: Realm +) { + suspend fun saveAuditEvents(profileId: ProfileIdentifier, events: JsonElement): Int = + realm.tryWrite { + val profile = requireNotNull( + queryFirst( + "id = $0", + profileId + ) + ) { "No profile with id = $profileId found!" } + + val totalAuditEventsInBundle = extractAuditEvents(events) { id, taskId, description, timestamp -> + val entity = copyToRealm( + AuditEventEntityV1().apply { + this.id = id + this.text = description + this.timestamp = timestamp.toRealmInstant() + this.taskId = taskId + this.profile = profile + } + ) + + profile.auditEvents += entity + } + + totalAuditEventsInBundle + } + + fun latestAuditEventTimestamp(profileId: ProfileIdentifier) = + realm.query("profile.id = $0", profileId) + .max("timestamp") + .asFlow() + .map { + it?.toInstant() + } + + data class AuditPagingKey(val offset: Int) + + inner class AuditPagingSource(val profileId: ProfileIdentifier) : + PagingSource() { + private val profile = realm.query("id = $0", profileId).first() + + override fun getRefreshKey(state: PagingState): AuditPagingKey? = + null + + override suspend fun load(params: LoadParams): LoadResult { + val count = params.loadSize + val key = params.key ?: AuditPagingKey(0) + + val events = requireNotNull(profile.find()).auditEvents + val result = events.asReversed().subList(key.offset, min(key.offset + count, events.size)) + + val nextKey = if (result.size == count) { + AuditPagingKey( + key.offset + result.size + ) + } else { + null + } + val prevKey = if (key.offset == 0) null else key.copy(offset = max(0, key.offset - count)) + + val taskIds = result.distinctBy { it.taskId }.map { it.taskId } + + val medicationTexts = taskIds.associateWith { taskId -> + taskId?.let { + realm.queryFirst("taskId = $0", it)?.medicationRequest?.medication?.text + } + } + + return LoadResult.Page( + data = result.map { + AuditEventData.AuditEvent( + auditId = it.id, + medicationText = it.taskId?.let { taskId -> + medicationTexts[taskId] + }, + description = it.text, + timestamp = it.timestamp.toInstant() + ) + }, + nextKey = nextKey, + prevKey = prevKey, + itemsBefore = if (prevKey != null) count else 0, + itemsAfter = if (nextKey != null) count else 0 + ) + } + } + + fun auditEvents(profileId: ProfileIdentifier): Flow> = + Pager( + PagingConfig( + pageSize = AuditEventsMaxPageSize, + initialLoadSize = AuditEventsMaxPageSize * 2, + maxSize = AuditEventsMaxPageSize * 3 + ), + pagingSourceFactory = { AuditPagingSource(profileId) } + ).flow +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetailTest.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/RemoteDataSource.kt similarity index 52% rename from android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetailTest.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/RemoteDataSource.kt index 9a159406..3601c014 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetailTest.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/RemoteDataSource.kt @@ -16,28 +16,28 @@ * */ -package de.gematik.ti.erp.app.prescription.detail.ui.model +package de.gematik.ti.erp.app.protocol.repository -import de.gematik.ti.erp.app.utils.detailPrescriptionScanned -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.time.OffsetDateTime +import de.gematik.ti.erp.app.api.ErpService +import de.gematik.ti.erp.app.api.safeApiCall +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier -class UIPrescriptionDetailTest { - - private lateinit var uiPrescriptionDetail: UIPrescriptionDetailScanned - private lateinit var scannedOn: OffsetDateTime - - @Before - fun setUp() { - scannedOn = OffsetDateTime.now() - uiPrescriptionDetail = detailPrescriptionScanned(scannedOn) - } - - @Test - fun `test format date`() { - val actual = uiPrescriptionDetail.formattedScannedInfo("at") - assertTrue(actual.contains("at")) +class AuditEventRemoteDataSource( + private val service: ErpService +) { + suspend fun getAuditEvents( + profileId: ProfileIdentifier, + lastKnownUpdate: String?, + count: Int? = null, + offset: Int? = null + ) = safeApiCall( + errorMessage = "Error getting all audit events" + ) { + service.getAuditEvents( + profileId = profileId, + lastKnownDate = lastKnownUpdate, + count = count, + offset = offset + ) } } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/GeneralSettings.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/GeneralSettings.kt new file mode 100644 index 00000000..4b5b6d16 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/GeneralSettings.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings + +import de.gematik.ti.erp.app.settings.model.SettingsData +import kotlinx.coroutines.flow.Flow +import java.time.Instant + +interface GeneralSettings { + val general: Flow + + suspend fun acceptUpdatedDataTerms(now: Instant = Instant.now()) + suspend fun saveOnboardingSucceededData( + authenticationMode: SettingsData.AuthenticationMode, + profileName: String, + now: Instant = Instant.now() + ) + + suspend fun saveAuthenticationMode(mode: SettingsData.AuthenticationMode) + val authenticationMode: Flow + + suspend fun saveZoomPreference(enabled: Boolean) + + suspend fun acceptInsecureDevice() + + suspend fun incrementNumberOfAuthenticationFailures() + suspend fun resetNumberOfAuthenticationFailures() + suspend fun saveWelcomeDrawerShown() +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/PharmacySettings.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/PharmacySettings.kt new file mode 100644 index 00000000..72fe56da --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/PharmacySettings.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings + +import de.gematik.ti.erp.app.settings.model.SettingsData +import kotlinx.coroutines.flow.Flow + +interface PharmacySettings { + suspend fun savePharmacySearch(search: SettingsData.PharmacySearch) + val pharmacySearch: Flow +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/model/SettingsData.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/model/SettingsData.kt new file mode 100644 index 00000000..52b19577 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/model/SettingsData.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.model + +import de.gematik.ti.erp.app.secureRandomInstance +import java.security.MessageDigest +import java.time.Instant + +object SettingsData { + data class General( + val latestAppVersion: AppVersion, + val onboardingShownIn: AppVersion?, + val welcomeDrawerShown: Boolean, + val dataProtectionVersionAcceptedOn: Instant, + val zoomEnabled: Boolean, + val userHasAcceptedInsecureDevice: Boolean, + val authenticationFails: Int + ) + + data class AppVersion( + val code: Int, + val name: String + ) + + data class PharmacySearch( + val name: String, + val locationEnabled: Boolean, + val ready: Boolean = false, + val deliveryService: Boolean = false, + val onlineService: Boolean = false, + val openNow: Boolean = false + ) { + fun isAnySet(): Boolean = + ready || deliveryService || onlineService || openNow + } + + sealed class AuthenticationMode { + object DeviceSecurity : AuthenticationMode() + class Password : AuthenticationMode { + val hash: ByteArray + val salt: ByteArray + + constructor(password: String) { + salt = ByteArray(32).apply { secureRandomInstance().nextBytes(this) } + hash = hashWithSalt(password, salt) + } + + constructor(hash: ByteArray, salt: ByteArray) { + this.hash = hash + this.salt = salt + } + + fun isValid(password: String): Boolean { + val hash = hashWithSalt(password, salt) + return hash.contentEquals(this.hash) + } + + private fun hashWithSalt(password: String, salt: ByteArray): ByteArray { + val combined = password.toByteArray() + salt + return MessageDigest.getInstance("SHA-256").digest(combined) + } + } + + object Unspecified : AuthenticationMode() + + @Deprecated("replaced by deviceSecurity") + object Biometrics : AuthenticationMode() + + @Deprecated("replaced by deviceSecurity") + object DeviceCredentials : AuthenticationMode() + + @Deprecated("not available anymore") + object None : AuthenticationMode() + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt new file mode 100644 index 00000000..2cd989ef --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsAuthenticationMethodV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.toInstant +import de.gematik.ti.erp.app.db.toRealmInstant +import de.gematik.ti.erp.app.db.writeToRealm +import de.gematik.ti.erp.app.settings.GeneralSettings +import de.gematik.ti.erp.app.settings.PharmacySettings +import de.gematik.ti.erp.app.settings.model.SettingsData +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +import java.time.Instant +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withContext + +class SettingsRepository constructor( + private val dispatchers: DispatchProvider, + private val realm: Realm +) : GeneralSettings, PharmacySettings { + private val settings: Flow + get() = realm.query().first().asFlow().map { it.obj } + + override val general: Flow + get() = realm.query().first().asFlow().mapNotNull { query -> + query.obj?.let { + SettingsData.General( + latestAppVersion = SettingsData.AppVersion( + code = it.latestAppVersionCode, + name = it.latestAppVersionName + ), + onboardingShownIn = if (it.onboardingLatestAppVersionCode != -1) { + SettingsData.AppVersion( + code = it.onboardingLatestAppVersionCode, + name = it.onboardingLatestAppVersionName + ) + } else { + null + }, + welcomeDrawerShown = it.welcomeDrawerShown, + dataProtectionVersionAcceptedOn = it.dataProtectionVersionAccepted.toInstant(), + zoomEnabled = it.zoomEnabled, + userHasAcceptedInsecureDevice = it.userHasAcceptedInsecureDevice, + authenticationFails = it.authenticationFails + ) + } + }.flowOn(dispatchers.IO) + + override val authenticationMode: Flow + get() = realm.query().first().asFlow().mapNotNull { query -> + query.obj?.let { + when (it.authenticationMethod) { + SettingsAuthenticationMethodV1.DeviceSecurity -> SettingsData.AuthenticationMode.DeviceSecurity + SettingsAuthenticationMethodV1.Password -> { + it.password?.let { pw -> + SettingsData.AuthenticationMode.Password( + hash = pw.hash, + salt = pw.salt + ) + } + } + + SettingsAuthenticationMethodV1.Biometrics -> SettingsData.AuthenticationMode.Biometrics + SettingsAuthenticationMethodV1.DeviceCredentials -> SettingsData.AuthenticationMode.DeviceCredentials + SettingsAuthenticationMethodV1.None -> SettingsData.AuthenticationMode.None + else -> SettingsData.AuthenticationMode.Unspecified + } + } + }.flowOn(dispatchers.IO) + + override val pharmacySearch: Flow + get() = settings.mapNotNull { settings -> + settings?.pharmacySearch?.let { + SettingsData.PharmacySearch( + name = it.name, + locationEnabled = it.locationEnabled, + ready = it.filterReady, + deliveryService = it.filterDeliveryService, + onlineService = it.filterOnlineService, + openNow = it.filterOpenNow + ) + } + }.flowOn(dispatchers.IO) + + override suspend fun savePharmacySearch(search: SettingsData.PharmacySearch) { + writeToRealm { + this.pharmacySearch?.apply { + this.name = search.name + this.locationEnabled = search.locationEnabled + this.filterReady = search.ready + this.filterDeliveryService = search.deliveryService + this.filterOnlineService = search.onlineService + this.filterOpenNow = search.openNow + } + } + } + + override suspend fun saveZoomPreference(enabled: Boolean) { + writeToRealm { + this.zoomEnabled = enabled + } + } + + override suspend fun saveAuthenticationMode(mode: SettingsData.AuthenticationMode) { + writeToRealm { + this.setAuthenticationMode(mode) + } + } + + private fun SettingsEntityV1.setAuthenticationMode(mode: SettingsData.AuthenticationMode) { + this.authenticationMethod = when (mode) { + SettingsData.AuthenticationMode.DeviceSecurity -> SettingsAuthenticationMethodV1.DeviceSecurity + is SettingsData.AuthenticationMode.Password -> SettingsAuthenticationMethodV1.Password + else -> SettingsAuthenticationMethodV1.Unspecified + } + if (mode is SettingsData.AuthenticationMode.Password) { + this.authenticationMethod = SettingsAuthenticationMethodV1.Password + this.password?.apply { + this.hash = mode.hash + this.salt = mode.salt + } + } else { + this.password?.reset() + } + } + + override suspend fun saveOnboardingSucceededData( + authenticationMode: SettingsData.AuthenticationMode, + profileName: String, + now: Instant + ) { + withContext(dispatchers.IO) { + realm.writeToRealm { settings -> + copyToRealm( + ProfileEntityV1().apply { + this.name = profileName + this.active = true + } + ) + settings.setAuthenticationMode(authenticationMode) + settings.setAcceptedUpdatedDataTerms(now) + settings.setOnboardingAppVersion() + } + } + } + + override suspend fun incrementNumberOfAuthenticationFailures() { + writeToRealm { + this.authenticationFails += 1 + } + } + + override suspend fun resetNumberOfAuthenticationFailures() { + writeToRealm { + this.authenticationFails = 0 + } + } + + override suspend fun saveWelcomeDrawerShown() { + writeToRealm { + this.welcomeDrawerShown = true + } + } + + override suspend fun acceptInsecureDevice() { + writeToRealm { + this.userHasAcceptedInsecureDevice = true + } + } + + override suspend fun acceptUpdatedDataTerms(now: Instant) { + writeToRealm { + this.setAcceptedUpdatedDataTerms(now) + } + } + + private fun SettingsEntityV1.setAcceptedUpdatedDataTerms(now: Instant) { + this.dataProtectionVersionAccepted = now.toRealmInstant() + } + + private fun SettingsEntityV1.setOnboardingAppVersion() { + this.onboardingLatestAppVersionName = this.latestAppVersionName + this.onboardingLatestAppVersionCode = this.latestAppVersionCode + } + + private suspend fun writeToRealm(block: SettingsEntityV1.() -> Unit) { + withContext(dispatchers.IO) { + realm.writeToRealm { + it.block() + } + } + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/utils/Bytes.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/utils/Bytes.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/utils/Bytes.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/utils/Bytes.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/CertUtils.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/CertUtils.kt similarity index 98% rename from android/src/main/java/de/gematik/ti/erp/app/vau/CertUtils.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/CertUtils.kt index 74894026..06d1da88 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/CertUtils.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/CertUtils.kt @@ -47,7 +47,7 @@ fun List>.filterByOIDAndOCSPResponse( validOcspResponse.findValidCert(chain.first().serialNumber)?.let { val thisUpdate = it.thisUpdate.toInstant() - (producedAt <= thisUpdate) && (thisUpdate <= timestamp) && + (producedAt <= timestamp) && (thisUpdate <= timestamp) && it.matchesIssuer(chain[1]) // TODO not present in test responses // && it.matchesHashOfCertificate(chain[0]) diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/ClientCrypto.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/ClientCrypto.kt similarity index 98% rename from android/src/main/java/de/gematik/ti/erp/app/vau/ClientCrypto.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/ClientCrypto.kt index 37a33ae6..cc46944a 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/ClientCrypto.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/ClientCrypto.kt @@ -71,7 +71,7 @@ class VauChannelSpec constructor( val decryptionKeySize: Int, val specEcies: VauEciesSpec, - val specAesGcm: VauAesGcmSpec, + val specAesGcm: VauAesGcmSpec ) { /** * Raw request data holding the previously used request id (hex encoded) and the decryption key. @@ -187,7 +187,7 @@ class VauChannelSpec constructor( publicKey: ECPublicKey, baseUrl: HttpUrl, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig, + cryptoConfig: VauCryptoConfig = defaultCryptoConfig ): Pair { val bearer = requireNotNull( innerRequest.header("Authorization") @@ -266,7 +266,7 @@ class VauChannelSpec constructor( requestIdSize = 16, decryptionKeySize = 16, specEcies = VauEciesSpec.V1, - specAesGcm = VauAesGcmSpec.V1, + specAesGcm = VauAesGcmSpec.V1 ) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/Crypto.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/Crypto.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/vau/Crypto.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/Crypto.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/OCSPUtils.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/OCSPUtils.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/vau/OCSPUtils.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/OCSPUtils.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/Utils.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/Utils.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/vau/Utils.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/Utils.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/api/VauService.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/api/VauService.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/vau/api/VauService.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/api/VauService.kt diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/model/VauModels.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/api/model/VauModels.kt similarity index 100% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/model/VauModels.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/api/model/VauModels.kt diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/model/Serializers.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/api/model/VauSerializers.kt similarity index 100% rename from desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/api/model/Serializers.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/api/model/VauSerializers.kt diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauLocalDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauLocalDataSource.kt similarity index 53% rename from android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauLocalDataSource.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauLocalDataSource.kt index abc84efe..ad11d599 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauLocalDataSource.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauLocalDataSource.kt @@ -18,27 +18,35 @@ package de.gematik.ti.erp.app.vau.repository -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.TruststoreEntity +import de.gematik.ti.erp.app.db.entities.v1.TruststoreEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.db.writeOrCopyToRealm import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList -import javax.inject.Inject +import io.realm.kotlin.Realm +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json -class VauLocalDataSource @Inject constructor( - private val db: AppDatabase +class VauLocalDataSource( + private val realm: Realm ) { suspend fun saveLists(certList: UntrustedCertList, ocspList: UntrustedOCSPList) { - db.truststoreDao().insert(TruststoreEntity(certList, ocspList)) + realm.writeOrCopyToRealm(::TruststoreEntityV1) { + it.certListJson = Json.encodeToString(certList) + it.ocspListJson = Json.encodeToString(ocspList) + } } - suspend fun loadUntrusted(): Pair? { - return db.truststoreDao().getUntrusted()?.let { - Pair(it.certList, it.ocspList) + fun loadUntrusted(): Pair? = + realm.queryFirst()?.let { + Pair(Json.decodeFromString(it.certListJson), Json.decodeFromString(it.ocspListJson)) } - } suspend fun deleteAll() { - db.truststoreDao().deleteAll() + realm.writeOrCopyToRealm(::TruststoreEntityV1) { + delete(it) + } } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauRemoteDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauRemoteDataSource.kt similarity index 91% rename from android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauRemoteDataSource.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauRemoteDataSource.kt index 5aed4ab0..a31f7171 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauRemoteDataSource.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauRemoteDataSource.kt @@ -18,13 +18,11 @@ package de.gematik.ti.erp.app.vau.repository -import de.gematik.ti.erp.app.api.Result import de.gematik.ti.erp.app.api.safeApiCall import de.gematik.ti.erp.app.vau.api.VauService import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList -import javax.inject.Inject -class VauRemoteDataSource @Inject constructor( +class VauRemoteDataSource( private val service: VauService ) { suspend fun loadCertificates() = diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauRepository.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauRepository.kt similarity index 72% rename from android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauRepository.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauRepository.kt index a5f253fa..32038458 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/repository/VauRepository.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/repository/VauRepository.kt @@ -19,41 +19,34 @@ package de.gematik.ti.erp.app.vau.repository import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.api.Result import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList import kotlinx.coroutines.async import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject +import io.github.aakira.napier.Napier -class VauRepository @Inject constructor( +class VauRepository( private val localDataSource: VauLocalDataSource, private val remoteDataSource: VauRemoteDataSource, - private val dispatchProvider: DispatchProvider + private val dispatchers: DispatchProvider ) { /** * Catches all exceptions originating from [block], deletes the locally saved untrusted store and * rethrows the exception. */ suspend fun withUntrusted(block: suspend (UntrustedCertList, UntrustedOCSPList) -> R) = - withContext(dispatchProvider.io()) { + withContext(dispatchers.IO) { val (untrustedCertList, untrustedOCSPList) = localDataSource.loadUntrusted() ?: run { - Timber.d("GET cert & ocsp from backend...") + Napier.d("GET cert & ocsp from backend...") val certsResult = async { remoteDataSource.loadCertificates() } val ocspResult = async { remoteDataSource.loadOcspResponses() } - val certs = when (val r = certsResult.await()) { - is Result.Error -> throw r.exception - is Result.Success -> r.data - } - val ocsp = when (val r = ocspResult.await()) { - is Result.Error -> throw r.exception - is Result.Success -> r.data - } + val certs = certsResult.await().getOrThrow() - Timber.d("...GET cert & ocsp from backend was successful") + val ocsp = ocspResult.await().getOrThrow() + + Napier.d("...GET cert & ocsp from backend was successful") Pair(certs, ocsp) } diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/usecase/TruststoreConfig.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreConfig.kt similarity index 80% rename from android/src/main/java/de/gematik/ti/erp/app/vau/usecase/TruststoreConfig.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreConfig.kt index b1af8a35..f4e3580b 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/usecase/TruststoreConfig.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreConfig.kt @@ -18,20 +18,17 @@ package de.gematik.ti.erp.app.vau.usecase -import android.util.Base64 import de.gematik.ti.erp.app.BuildKonfig import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.util.encoders.Base64 import java.time.Duration -import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class TruststoreConfig @Inject constructor() { +class TruststoreConfig(getTrustAnchor: () -> String) { val maxOCSPResponseAge: Duration by lazy { Duration.ofHours(BuildKonfig.VAU_OCSP_RESPONSE_MAX_AGE) } val trustAnchor by lazy { - X509CertificateHolder(Base64.decode(BuildKonfig.APP_TRUST_ANCHOR_BASE64, Base64.DEFAULT)) + X509CertificateHolder(Base64.decode(getTrustAnchor())) } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt similarity index 91% rename from android/src/main/java/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt index 31f0aa7c..8c8583e6 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt @@ -31,13 +31,11 @@ import kotlinx.coroutines.sync.withLock import org.bouncycastle.cert.X509CertificateHolder import org.bouncycastle.cert.ocsp.BasicOCSPResp import org.bouncycastle.jcajce.provider.asymmetric.ec.KeyFactorySpi -import timber.log.Timber +import io.github.aakira.napier.Napier import java.security.cert.X509Certificate import java.security.interfaces.ECPublicKey import java.time.Duration import java.time.Instant -import javax.inject.Inject -import javax.inject.Singleton private const val RCA_PREFIX = "GEM.RCA" private const val CA_PREFIX = "GEM.KOMP-CA" @@ -52,34 +50,21 @@ private val vauOid = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 2) // oid = */ private val idpOid = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 4) // oid = 1.2.276.0.76.4.260 -class TruststoreTimeSourceProvider @Inject constructor() { - fun now(): Instant = - Instant.now() -} +typealias TruststoreTimeSourceProvider = () -> Instant -class TrustedTruststoreProvider @Inject constructor() { - fun create( - untrustedOCSPList: UntrustedOCSPList, - untrustedCertList: UntrustedCertList, - trustAnchor: X509CertificateHolder, - ocspResponseMaxAge: Duration, - timestamp: Instant - ): TrustedTruststore = - TrustedTruststore.create( - untrustedOCSPList, - untrustedCertList, - trustAnchor, - ocspResponseMaxAge, - timestamp - ) -} +typealias TrustedTruststoreProvider = ( + untrustedOCSPList: UntrustedOCSPList, + untrustedCertList: UntrustedCertList, + trustAnchor: X509CertificateHolder, + ocspResponseMaxAge: Duration, + timestamp: Instant +) -> TrustedTruststore -@Singleton -class TruststoreUseCase @Inject constructor( +class TruststoreUseCase( private val config: TruststoreConfig, private val repository: VauRepository, private val timeSourceProvider: TruststoreTimeSourceProvider, - private val trustedTruststoreProvider: TrustedTruststoreProvider, + private val trustedTruststoreProvider: TrustedTruststoreProvider ) { private val lock = Mutex() private var cachedTruststore: TrustedTruststore? = null @@ -94,7 +79,9 @@ class TruststoreUseCase @Inject constructor( idpCertificate: X509CertificateHolder, invalidateStoreOnFailure: Boolean = false ) = lock.withLock { - val timestamp = timeSourceProvider.now() + val timestamp = timeSourceProvider() + + Napier.d("Check IDP certificate with truststore") val exception = withLoadedStore(timestamp) { store -> try { @@ -116,7 +103,7 @@ class TruststoreUseCase @Inject constructor( } suspend fun withValidVauPublicKey(block: (vauPubKey: ECPublicKey) -> R): R = lock.withLock { - val timestamp = timeSourceProvider.now() + val timestamp = timeSourceProvider() withLoadedStore(timestamp) { block(it.vauPublicKey) @@ -130,7 +117,7 @@ class TruststoreUseCase @Inject constructor( private suspend fun withLoadedStore(timestamp: Instant, block: (TrustedTruststore) -> R): R { try { val store = cachedTruststore?.let { - Timber.d("Use cached truststore...") + Napier.d("Use cached truststore...") try { it.checkValidity(config.maxOCSPResponseAge, timestamp) @@ -145,7 +132,7 @@ class TruststoreUseCase @Inject constructor( createTrustedTruststore(timestamp) } } ?: run { - Timber.d("Create truststore from repository...") + Napier.d("Create truststore from repository...") try { createTrustedTruststore(timestamp) @@ -172,10 +159,10 @@ class TruststoreUseCase @Inject constructor( } private suspend fun createTrustedTruststore(timestamp: Instant): TrustedTruststore { - Timber.d("Load truststore from repository...") + Napier.d("Load truststore from repository...") return repository.withUntrusted { untrustedCertList, untrustedOCSPList -> - trustedTruststoreProvider.create( + trustedTruststoreProvider( untrustedOCSPList, untrustedCertList, config.trustAnchor, @@ -303,7 +290,7 @@ fun findValidOcspResponses( // return valid response ocspResponse } catch (e: Exception) { - Timber.d(e) + Napier.d("OCSP response not valid", e) null } } diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/CoroutineTestRule.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/CoroutineTestRule.kt new file mode 100644 index 00000000..6b7e4367 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/CoroutineTestRule.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class CoroutineTestRule( + private val testDispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + + val dispatchers = object : DispatchProvider { + override val Default: CoroutineDispatcher get() = testDispatcher + override val IO: CoroutineDispatcher get() = testDispatcher + override val Main: CoroutineDispatcher get() = testDispatcher + override val Unconfined: CoroutineDispatcher get() = testDispatcher + } + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + testDispatcher.cancel() + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/api/ResourcePagingTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/api/ResourcePagingTest.kt new file mode 100644 index 00000000..8426428a --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/api/ResourcePagingTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.api + +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private class PageTestContainer(dispatchers: DispatchProvider) : ResourcePaging(dispatchers, 50) { + + suspend fun downloadAll(profileId: ProfileIdentifier) = downloadPaged(profileId) + + override suspend fun downloadResource(profileId: ProfileIdentifier, timestamp: String?, count: Int?): Result { + assertEquals("gt2022-03-22T12:30:00Z", timestamp) + assertEquals(50, count) + return Result.success(10) + } + + override suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? = + Instant.parse("2022-03-22T12:30:00.00Z") +} + +private class PageTestContainerWithError(dispatchers: DispatchProvider) : ResourcePaging(dispatchers, 50) { + + suspend fun downloadAll(profileId: ProfileIdentifier) = downloadPaged(profileId) + + override suspend fun downloadResource(profileId: ProfileIdentifier, timestamp: String?, count: Int?): Result { + assertEquals("gt2022-03-22T12:30:00Z", timestamp) + assertEquals(50, count) + return Result.failure(IllegalArgumentException()) + } + + override suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? = + Instant.parse("2022-03-22T12:30:00.00Z") +} + +private class PageTestContainerWithMultiplePages(dispatchers: DispatchProvider) : ResourcePaging(dispatchers, 50) { + + private var page = 0 + + suspend fun downloadAll(profileId: ProfileIdentifier) = downloadPaged(profileId) + + override suspend fun downloadResource(profileId: ProfileIdentifier, timestamp: String?, count: Int?): Result { + assertEquals("gt2022-03-22T12:30:00Z", timestamp) + assertEquals(50, count) + return when (page) { + 0 -> Result.success(50) + 1 -> Result.success(30) + else -> error("") + }.also { + page++ + } + } + + override suspend fun syncedUpTo(profileId: ProfileIdentifier): Instant? = + Instant.parse("2022-03-22T12:30:00.00Z") +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ResourcePagingTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + @Test + fun `paging with less results than max page size`() = runTest { + val paging = PageTestContainer(coroutineRule.dispatchers) + val result = paging.downloadAll("") + assertTrue { result.isSuccess } + } + + @Test + fun `paging with error`() = runTest { + val paging = PageTestContainerWithError(coroutineRule.dispatchers) + val result = paging.downloadAll("") + assertTrue { result.isFailure } + } + + @Test + fun `paging with multiple pages`() = runTest { + val paging = PageTestContainerWithMultiplePages(coroutineRule.dispatchers) + val result = paging.downloadAll("") + assertTrue { result.isSuccess } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/RealmInstantConverterTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/RealmInstantConverterTest.kt new file mode 100644 index 00000000..69fb852f --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/RealmInstantConverterTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import io.realm.kotlin.types.RealmInstant +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals + +class RealmInstantConverterTest { + @Test + fun `RealmInstant to LocalDateTime`() { + val dt = RealmInstant.from(123456, 123456789).toLocalDateTime() + assertEquals(123456, dt.toEpochSecond(ZoneOffset.UTC)) + assertEquals(123456789, dt.nano) + } + + @Test + fun `LocalDateTime to RealmInstant`() { + val ri = LocalDateTime.ofEpochSecond(123456, 123456789, ZoneOffset.UTC).toRealmInstant() + assertEquals(123456, ri.epochSeconds) + assertEquals(123456789, ri.nanosecondsOfSecond) + } + + @Test + fun `RealmInstant to Instant`() { + val dt = RealmInstant.from(123456, 123456789).toInstant() + assertEquals(123456, dt.epochSecond) + assertEquals(123456789, dt.nano) + } + + @Test + fun `Instant to RealmInstant`() { + val ri = Instant.ofEpochSecond(123456, 123456789).toRealmInstant() + assertEquals(123456, ri.epochSeconds) + assertEquals(123456789, ri.nanosecondsOfSecond) + } + + @Test + fun `Convert with offset`() { + val dtPlus2 = OffsetDateTime.parse("2022-02-04T14:05:10+02:00") + val dtUTC = OffsetDateTime.parse("2022-02-04T12:05:10+00:00") + val timestampAtUTC = dtPlus2.toEpochSecond() + + assertEquals(dtUTC, dtPlus2.withOffsetSameInstant(ZoneOffset.UTC)) + + val realmInstantAtUTC = dtPlus2.toLocalDateTime().toRealmInstant(ZoneOffset.ofHours(2)) + assertEquals(timestampAtUTC, realmInstantAtUTC.epochSeconds) + + val localDateTimeAtPlus2 = realmInstantAtUTC.toLocalDateTime(ZoneOffset.ofHours(2)) + assertEquals(LocalDateTime.parse("2022-02-04T14:05:10"), localDateTimeAtPlus2) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/SchemaTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/SchemaTest.kt new file mode 100644 index 00000000..c50a5e5e --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/SchemaTest.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import io.mockk.spyk +import io.mockk.verify +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.ext.query +import kotlin.test.assertEquals +import kotlin.test.Test +import kotlin.test.assertTrue + +class RealmA_V1 : RealmObject { + var propA: Long = 0L + var propB: String = "b" +} + +class RealmB_V1 : RealmObject { + var propA: Int = 1 + var propB: Int = 2 +} + +class RealmA_V2 : RealmObject { + var propA: String = "a" + var propB: String = "b" + var propC: String = "c" +} + +class RealmA_V3 : RealmObject { + var propA: String = "a" + var propB: String = "b" + var propC: Int = 3 +} + +class SchemaTest : TestDB() { + @Test + fun `migrate from a new db`() { + val schemas = setOf( + AppRealmSchema( + version = 0, + classes = setOf(RealmA_V1::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(-1, migrationStartedFrom) + } + ), + AppRealmSchema( + version = 1, + classes = setOf(RealmA_V1::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(-1, migrationStartedFrom) + } + ), + AppRealmSchema( + version = 2, + classes = setOf(RealmA_V1::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(-1, migrationStartedFrom) + } + ), + AppRealmSchema( + version = 3, + classes = setOf(RealmA_V1::class, RealmB_V1::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(-1, migrationStartedFrom) + } + ), + AppRealmSchema( + version = 4, + classes = setOf(RealmA_V1::class, RealmB_V1::class, RealmA_V2::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(-1, migrationStartedFrom) + } + ) + ) + + var realm: Realm? = null + try { + realm = openRealmWith(schemas, configuration = { it.directory(tempDBPath) }) + + realm.schema().classes.let { classes -> + assertTrue { classes.any { it.name == "RealmA_V1" } } + assertTrue { classes.any { it.name == "RealmB_V1" } } + assertTrue { classes.any { it.name == "RealmA_V2" } } + } + } finally { + realm?.close() + } + } + + @Test + fun `migrate from existing db`() { + Realm.open( + RealmConfiguration.Builder( + schema = setOf(RealmA_V1::class, LatestManualMigration::class) + ) + .schemaVersion(0) + .directory(tempDBPath) + .build() + ).also { realm -> + realm.writeBlocking { + copyToRealm( + LatestManualMigration().apply { + version = 0 + } + ) + copyToRealm( + RealmA_V1().apply { + propA = 123L + propB = "Test" + } + ) + } + }.close() + + val noCallVerifier = spyk({}) + val callVerifier = spyk({}) + + val schemas = setOf( + + AppRealmSchema( + version = 0, + classes = setOf(RealmA_V1::class), + migrateOrInitialize = { + noCallVerifier() + } + ), + AppRealmSchema( + version = 1, + classes = setOf(RealmA_V1::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(0, migrationStartedFrom) + callVerifier() + } + ), + AppRealmSchema( + version = 2, + classes = setOf(RealmA_V1::class, RealmA_V2::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(0, migrationStartedFrom) + + val v1 = query().first().find() + + assertEquals(123L, v1?.propA) + assertEquals("Test", v1?.propB) + + v1?.let { + copyToRealm( + RealmA_V2().apply { + propA = v1.propA.toString() + propB = v1.propB + propC = "65" + } + ) + delete(v1) + } + + callVerifier() + } + ), + AppRealmSchema( + version = 3, + classes = setOf(RealmA_V1::class, RealmA_V2::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(0, migrationStartedFrom) + assertEquals(null, query().first().find()) + callVerifier() + } + ), + AppRealmSchema( + version = 4, + classes = setOf(RealmA_V1::class, RealmA_V2::class, RealmA_V3::class), + migrateOrInitialize = { migrationStartedFrom -> + assertEquals(0, migrationStartedFrom) + + val v2 = query().first().find() + + assertEquals("123", v2?.propA) + assertEquals("Test", v2?.propB) + assertEquals("65", v2?.propC) + + v2?.let { + copyToRealm( + RealmA_V3().apply { + propA = v2.propA + propB = v2.propB + propC = v2.propC.toInt() + } + ) + delete(v2) + } + callVerifier() + } + ) + ) + + var realm: Realm? = null + try { + realm = openRealmWith(schemas, configuration = { it.directory(tempDBPath) }) + + val v3 = realm.query().first().find() + assertEquals("123", v3?.propA) + assertEquals("Test", v3?.propB) + assertEquals(65, v3?.propC) + + verify(exactly = 0) { noCallVerifier() } + verify(exactly = 4) { callVerifier() } + } finally { + realm?.close() + } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/TestDB.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/TestDB.kt new file mode 100644 index 00000000..c473295e --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/TestDB.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db + +import java.io.File +import java.nio.file.Files +import kotlin.io.path.absolutePathString +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +private fun createTempDir() = Files.createTempDirectory("realm-db-test").absolutePathString() + +abstract class TestDB { + private lateinit var tempDirPath: String + lateinit var tempDBPath: String + + @BeforeTest + fun setUpPaths() { + tempDirPath = createTempDir() + tempDBPath = "$tempDirPath/default" + } + + @AfterTest + fun cleanUpPaths() { + File(tempDirPath).deleteRecursively() + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/DelegatesTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/DelegatesTest.kt new file mode 100644 index 00000000..ae0bf2af --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/DelegatesTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities + +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails +import org.bouncycastle.util.encoders.Base64 +import java.time.Instant +import java.time.Year +import java.time.temporal.TemporalAccessor + +private object Clazz { + enum class EnumA { + A, B, C + } + + object Clazz { + enum class EnumB { + A, B, C + } + } +} + +class DelegatesTest { + @Test + fun `name of enum is delegated`() { + val container = object { + var backingProp: String = "" + var prop: Clazz.EnumA by enumName(::backingProp) + } + + container.prop = Clazz.EnumA.A + assertEquals("A", container.backingProp) + assertEquals(Clazz.EnumA.A, container.prop) + + container.prop = Clazz.EnumA.B + assertEquals("B", container.backingProp) + assertEquals(Clazz.EnumA.B, container.prop) + + container.prop = Clazz.EnumA.C + assertEquals("C", container.backingProp) + assertEquals(Clazz.EnumA.C, container.prop) + } + + @Test + fun `name of enum is delegated from inner class`() { + val container = object { + var backingProp: String = "" + var prop: Clazz.Clazz.EnumB by enumName(::backingProp) + } + + container.prop = Clazz.Clazz.EnumB.A + assertEquals("A", container.backingProp) + assertEquals(Clazz.Clazz.EnumB.A, container.prop) + + container.prop = Clazz.Clazz.EnumB.B + assertEquals("B", container.backingProp) + assertEquals(Clazz.Clazz.EnumB.B, container.prop) + + container.prop = Clazz.Clazz.EnumB.C + assertEquals("C", container.backingProp) + assertEquals(Clazz.Clazz.EnumB.C, container.prop) + } + + @Test + fun `name of enum is delegated from backing property`() { + val container = object { + var backingProp: String = "" + var prop: Clazz.Clazz.EnumB by enumName(::backingProp) + } + + container.backingProp = "A" + assertEquals("A", container.backingProp) + assertEquals(Clazz.Clazz.EnumB.A, container.prop) + + container.backingProp = "B" + assertEquals("B", container.backingProp) + assertEquals(Clazz.Clazz.EnumB.B, container.prop) + + container.backingProp = "C" + assertEquals("C", container.backingProp) + assertEquals(Clazz.Clazz.EnumB.C, container.prop) + } + + @Test + fun `name of enum is delegated from backing property - backing property contains invalid name`() { + val container = object { + var backingProp: String = "" + var prop: Clazz.Clazz.EnumB by enumName(::backingProp) + } + + container.backingProp = "ABC" + assertFails { + container.prop + } + } + + @Test + fun `name of enum is delegated from backing property - backing property contains invalid name - returns default`() { + val container = object { + var backingProp: String = "" + var prop: Clazz.Clazz.EnumB by enumName(::backingProp, Clazz.Clazz.EnumB.B) + } + + container.backingProp = "ABC" + assertEquals(Clazz.Clazz.EnumB.B, container.prop) + assertEquals("ABC", container.backingProp) + } + + @Test + fun `transform byte array to base64 and back again`() { + val origBacking = ByteArray(512).apply { + Random.nextBytes(this) + } + + val container = object { + var backingProp: String = Base64.toBase64String(origBacking) + var prop: ByteArray by byteArrayBase64(::backingProp) + } + + assertContentEquals(origBacking, container.prop) + + val orig = ByteArray(512).apply { + Random.nextBytes(this) + } + container.prop = orig.clone() + + assertContentEquals(orig, container.prop) + } + + @Test + fun `date time parsing`() { + val container = object { + var backingProp: String? = "2015-02-07T13:28:17.243+00:00" + var prop: TemporalAccessor? by temporalAccessorNullable(::backingProp) + } + + assertEquals(Instant.parse("2015-02-07T13:28:17.243+00:00"), container.prop) + + container.prop = Year.parse("2023") + + assertEquals("2023", container.backingProp) + assertEquals(Year.parse("2023"), container.prop) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/EntityUtilsTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/EntityUtilsTest.kt new file mode 100644 index 00000000..054833ec --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/EntityUtilsTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities + +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.queryFirst +import io.realm.kotlin.Deleteable +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.ext.query +import io.realm.kotlin.ext.realmListOf +import io.realm.kotlin.ext.toRealmList +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TestEntryRealm : RealmObject { + var prop: String = "TestEntryRealm" +} + +class TestEntryWithListRealmB : RealmObject, Cascading { + var prop: String = "TestEntryWithListRealmB" + var list: RealmList = realmListOf() + + override fun objectsToFollow(): Iterator = + iterator { + yield(list) + } +} + +class TestEntryWithListRealmA : RealmObject, Cascading { + var prop: String = "TestEntryWithListRealmA" + var list: RealmList = realmListOf() + + override fun objectsToFollow(): Iterator = + iterator { + yield(list) + } +} + +class TestRealm : RealmObject, Cascading { + var prop: String = "TestRealm" + var singleEntry: TestEntryWithListRealmA? = null + var list: RealmList = realmListOf() + + override fun objectsToFollow(): Iterator = + iterator { + singleEntry?.let { yield(it) } + yield(list) + } +} + +class EntityUtilsTest : TestDB() { + lateinit var realm: Realm + + @BeforeTest + fun setUp() { + realm = Realm.open( + RealmConfiguration.Builder( + schema = setOf( + TestRealm::class, + TestEntryWithListRealmA::class, + TestEntryWithListRealmB::class, + TestEntryRealm::class + ) + ) + .schemaVersion(0) + .directory(tempDBPath) + .build() + ).also { realm -> + realm.writeBlocking { + copyToRealm( + TestRealm().apply { + this.singleEntry = TestEntryWithListRealmA() + this.list = (1..5).map { a -> + TestEntryWithListRealmA().apply { + this.prop = "a: $a" + this.list = (1..4).map { b -> + TestEntryWithListRealmB().apply { + this.prop = "a: $a b: $b" + this.list = (1..3).map { c -> + TestEntryRealm().apply { + this.prop = "a: $a b: $b c: $c" + } + }.toRealmList() + } + }.toRealmList() + } + }.toRealmList() + } + ) + } + } + } + + @AfterTest + fun cleanUp() { + realm.close() + } + + @Test + fun `cascading delete - max depth`() { + val result = realm.queryFirst()?.flatten()!!.objectIterator() + + assertEquals("a: 1 b: 1 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 1 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 1 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 2 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 2 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 2 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 3 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 3 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 3 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 4 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 4 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 4 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 1 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 1 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 1 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 1 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 1 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 1 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 1 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 2 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 2 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 2 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 3 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 3 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 3 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 4 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 4 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 4 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 2 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 1 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 1 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 1 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 2 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 2 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 2 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 3 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 3 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 3 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 4 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 4 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 4 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 3 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 1 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 1 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 1 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 2 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 2 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 2 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 3 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 3 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 3 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 4 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 4 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 4 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 4 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 1 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 1 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 1 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 2 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 2 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 2 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 3 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 3 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 3 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 4 c: 1".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 4 c: 2".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 4 c: 3".trim(), (result.next() as TestEntryRealm).prop) + assertEquals("a: 5 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("TestEntryWithListRealmA".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 1 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 2 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 3 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 4 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + + realm.writeBlocking { + val resultsToDelete = queryFirst()!! + deleteAll(resultsToDelete) + } + + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + } + + @Test + fun `cascading delete - depth 1`() { + val result = realm.queryFirst()?.flatten(maxDepth = 1)!!.objectIterator() + + assertEquals("a: 1 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 1 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 1 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 1 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 2 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 3 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 4 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 1 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 2 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 3 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("a: 5 b: 4 ".trim(), (result.next() as TestEntryWithListRealmB).prop) + assertEquals("TestEntryWithListRealmA".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 1 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 2 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 3 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 4 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + + realm.writeBlocking { + val resultsToDelete = queryFirst()!! + deleteAll(resultsToDelete, maxDepth = 1) + } + + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(60, realm.query().count().find()) + } + + @Test + fun `cascading delete - depth 0`() { + val result = realm.queryFirst()?.flatten(maxDepth = 0)!!.objectIterator() + + assertEquals("TestEntryWithListRealmA".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 1 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 2 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 3 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + assertEquals("a: 4 ".trim(), (result.next() as TestEntryWithListRealmA).prop) + + realm.writeBlocking { + val resultsToDelete = queryFirst()!! + deleteAll(resultsToDelete, maxDepth = 0) + } + + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(20, realm.query().count().find()) + assertEquals(60, realm.query().count().find()) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SettingsEntityV1Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SettingsEntityV1Test.kt new file mode 100644 index 00000000..99d09f7a --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SettingsEntityV1Test.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.entities.deleteAll +import de.gematik.ti.erp.app.db.queryFirst +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.ext.query + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SettingsEntityV1Test : TestDB() { + @Test + fun `cascading delete`() { + Realm.open( + RealmConfiguration.Builder( + schema = setOf( + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + ShippingContactEntityV1::class, + PharmacySearchEntityV1::class, + AddressEntityV1::class + ) + ) + .schemaVersion(0) + .directory(tempDBPath) + .build() + ).also { realm -> + realm.writeBlocking { + copyToRealm( + SettingsEntityV1().apply { + this.pharmacySearch = PharmacySearchEntityV1() + this.password = PasswordEntityV1() + } + ) + } + + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + + realm.writeBlocking { + val settings = queryFirst()!! + deleteAll(settings) + } + + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt new file mode 100644 index 00000000..c4323e81 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.db.entities.v1 + +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.entities.deleteAll +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.TaskStatusV1 +import de.gematik.ti.erp.app.db.queryFirst +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.ext.query +import io.realm.kotlin.ext.realmListOf +import kotlin.test.Test +import kotlin.test.assertEquals + +class SyncedTaskEntityV1Test : TestDB() { + @Test + fun `cascading delete`() { + Realm.open( + RealmConfiguration.Builder( + schema = setOf( + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + TruststoreEntityV1::class, + SafetynetAttestationEntityV1::class, + IdpConfigurationEntityV1::class, + ProfileEntityV1::class, + CommunicationEntityV1::class, + MedicationEntityV1::class, + MedicationDispenseEntityV1::class, + MedicationRequestEntityV1::class, + OrganizationEntityV1::class, + PatientEntityV1::class, + PractitionerEntityV1::class, + ScannedTaskEntityV1::class, + SyncedTaskEntityV1::class, + AuditEventEntityV1::class, + IdpAuthenticationDataEntityV1::class, + AddressEntityV1::class, + InsuranceInformationEntityV1::class, + ShippingContactEntityV1::class, + IngredientEntityV1::class, + QuantityEntityV1::class, + RatioEntityV1::class, + MultiplePrescriptionInfoEntityV1::class + ) + ) + .schemaVersion(0) + .directory(tempDBPath) + .build() + ).also { realm -> + realm.writeBlocking { + copyToRealm( + SyncedTaskEntityV1().apply { + this.taskId = "123" + this.accessCode = "123" + this.lastModified = RealmInstant.MIN + this.expiresOn = RealmInstant.MIN + this.acceptUntil = RealmInstant.MIN + this.authoredOn = RealmInstant.MIN + this.organization = OrganizationEntityV1().apply { + this.address = AddressEntityV1() + } + this.practitioner = PractitionerEntityV1() + this.patient = PatientEntityV1().apply { + this.address = AddressEntityV1() + } + this.insuranceInformation = InsuranceInformationEntityV1() + this.status = TaskStatusV1.Ready + this.medicationRequest = MedicationRequestEntityV1().apply { + this.medication = MedicationEntityV1().apply { + this.amount = RatioEntityV1().apply { + this.numerator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "Tab" + } + this.denominator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "X" + } + } + this.ingredients = realmListOf( + IngredientEntityV1().apply { + this.strength = RatioEntityV1().apply { + this.numerator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "Tab" + } + this.denominator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "X" + } + } + } + ) + } + this.multiplePrescriptionInfo = MultiplePrescriptionInfoEntityV1().apply { + this.indicator = true + this.numbering = RatioEntityV1().apply { + this.denominator = QuantityEntityV1().apply { + this.value = "1" + } + } + } + } + this.medicationDispenses = realmListOf( + MedicationDispenseEntityV1().apply { + this.medication = MedicationEntityV1().apply { + this.amount = RatioEntityV1().apply { + this.numerator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "Tab" + } + this.denominator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "X" + } + } + this.ingredients = realmListOf( + IngredientEntityV1().apply { + this.strength = RatioEntityV1().apply { + this.numerator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "Tab" + } + this.denominator = QuantityEntityV1().apply { + this.value = "1" + this.unit = "X" + } + } + } + ) + } + } + ) + this.communications = realmListOf( + CommunicationEntityV1() + ) + } + ) + } + + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(2, realm.query().count().find()) + assertEquals(2, realm.query().count().find()) + assertEquals(2, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(5, realm.query().count().find()) + assertEquals(9, realm.query().count().find()) + + realm.writeBlocking { + val syncedTasks = queryFirst()!! + deleteAll(syncedTasks) + } + + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + assertEquals(0, realm.query().count().find()) + } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/AuditEventMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/AuditEventMapperTest.kt new file mode 100644 index 00000000..5b2cb8c9 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/AuditEventMapperTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import kotlinx.serialization.json.Json +import java.io.File +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +private val testBundle by lazy { File("$ResourceBasePath/audit_events_bundle.json").readText() } + +class AuditEventMapperTest { + private class AuditEvent( + val id: String, + val taskId: String?, + val description: String, + val timestamp: Instant + ) + + @Suppress("MaxLineLength") + private val events = mapOf( + 0 to AuditEvent( + id = "01eb7f56-6820-a140-abdb-34aa9f2ab6ea", + taskId = null, + description = "Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.", + timestamp = Instant.parse("2022-01-13T15:44:15.816+00:00") + ), + 2 to AuditEvent( + id = "01eb7f56-75dc-6850-9729-d94c0839ab3b", + taskId = "169.000.000.000.026.84", + description = "Praxis Rainer Graf d' AgóstinoTEST-ONLY hat das Rezept mit der ID 169.000.000.000.026.84 eingestellt.", + timestamp = Instant.parse("2022-01-13T15:48:06.226+00:00") + ), + 7 to AuditEvent( + id = "01eb7f56-862a-e830-e470-120f0137c54e", + taskId = "169.000.000.000.026.84", + description = "Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.", + timestamp = Instant.parse("2022-01-13T15:52:39.806+00:00") + ) + ) + + @Test + fun `parse audit events`() { + var index = 0 + + extractAuditEvents( + Json.parseToJsonElement(testBundle) + ) { id, taskId, description, timestamp -> + events[index]?.let { ev -> + assertEquals(ev.id, id) + assertEquals(ev.taskId, taskId) + assertEquals(ev.description, description) + assertEquals(ev.timestamp, timestamp) + } + + index++ + } + + assertEquals(50, index) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationMapperTest.kt new file mode 100644 index 00000000..f700f0c1 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/CommunicationMapperTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import de.gematik.ti.erp.app.fhir.parser.contained +import de.gematik.ti.erp.app.fhir.parser.containedString +import kotlinx.serialization.json.Json +import java.io.File +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val JsonSymbols = "\"{}[]:" +private const val JsonSymbolsEscaped = "\\\"{}[]:" + +private val testBundle by lazy { File("$ResourceBasePath/communications_bundle.json").readText() } + +class CommunicationMapperTest { + @Test + fun `create disp req communication`() { + val json = createCommunicationDispenseRequest( + orderId = "orderId$JsonSymbols", + taskId = "taskId$JsonSymbols", + accessCode = "accessCode$JsonSymbols", + recipientTID = "recipientTID$JsonSymbols", + payload = CommunicationPayload( + version = "1", + supplyOptionsType = "onPremise", + name = "Anton Miller", + address = listOf("Some Street", "1234", JsonSymbols), + hint = "Oh no", + phone = "132342546547" + ) + ) + + val orderId = json + .contained("identifier") + .containedString("value") + + assertEquals("orderId$JsonSymbols", orderId) + + val reference = json + .contained("basedOn") + .containedString("reference") + + assertEquals("Task/taskId$JsonSymbols/\$accept?ac=accessCode$JsonSymbols", reference) + + val recipientTID = json + .contained("recipient") + .contained("identifier") + .containedString("value") + + assertEquals("recipientTID$JsonSymbols", recipientTID) + + val payload = json + .contained("payload") + .containedString("contentString") + + @Suppress("MaxLineLength") + assertEquals( + "{\"version\":\"1\",\"supplyOptionsType\":\"onPremise\",\"name\":\"Anton Miller\",\"address\":[\"Some Street\",\"1234\",\"$JsonSymbolsEscaped\"],\"hint\":\"Oh no\",\"phone\":\"132342546547\"}", + payload + ) + } + + @Suppress("LongParameterList") + private class Communication( + val taskId: String, + val communicationId: String, + val orderId: String?, + val profile: CommunicationProfile, + val sentOn: Instant, + val sender: String, + val recipient: String, + val payload: String? + ) + + @Suppress("MaxLineLength") + private val communications = mapOf( + 0 to Communication( + taskId = "160.000.000.030.926.11", + communicationId = "01eb8d02-199b-3080-fe9e-ef29caeda984", + orderId = null, + profile = CommunicationProfile.ErxCommunicationReply, + sentOn = Instant.parse("2022-07-06T15:02:03.984+00:00"), + sender = "3-TEST-TID", + recipient = "X110535768", + payload = "{\"version\":\"1\" , \"supplyOptionsType\":\"shipment\" , \"info_text\":\"11 Info\\/Para + HRcode\\/Para + DMC\\/noPara + URL\\/noPara\" , \"pickUpCodeHR\":\"T11__R03\" , \"pickUpCodeDMC\":\"\" , \"url\":\"\"}" + ), + 3 to Communication( + taskId = "160.000.000.030.926.11", + communicationId = "01eb8d01-9a8d-99b8-9277-24b66fb07635", + orderId = null, + profile = CommunicationProfile.ErxCommunicationDispReq, + sentOn = Instant.parse("2022-07-06T14:26:32.387+00:00"), + sender = "X110535768", + recipient = "3-TEST-TID", + payload = "{\"version\":\"1\",\"supplyOptionsType\":\"shipment\",\"name\":\"Prinzessin Lars Graf Freiherr von Schinder\",\"address\":[\"Siegburger Str. 155\",\"\",\"51105 Köln\"],\"hint\":\"\",\"phone\":\"01711111111\"}" + ) + ) + + @Test + fun `parse communications`() { + var index = 0 + + extractCommunications( + Json.parseToJsonElement(testBundle) + ) { taskId, communicationId, orderId, profile, sentOn, sender, recipient, payload -> + communications[index]?.let { com -> + assertEquals(com.taskId, taskId) + assertEquals(com.communicationId, communicationId) + assertEquals(com.orderId, orderId) + assertEquals(com.profile, profile) + assertEquals(com.sentOn, sentOn) + assertEquals(com.sender, sender) + assertEquals(com.recipient, recipient) + assertEquals(com.payload, payload) + } + + index++ + } + + assertEquals(15, index) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapperTest.kt new file mode 100644 index 00000000..a6ca15ca --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/KBVMapperTest.kt @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import kotlinx.serialization.json.Json +import java.time.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +enum class ReturnType { + Organization, Patient, Practitioner, InsuranceInformation, MedicationRequest, MedicationDispense, + Medication, Ingredient, MultiplePrescriptionInfo, Quantity, Ratio, Address +} + +class KBVMapperTest { + + @Test + fun `process organization`() { + val organization = Json.parseToJsonElement(organizationJson) + + val result = extractOrganization( + organization, + processAddress = { line, postalCode, city -> + assertEquals(listOf("Herbert-Lewin-Platz 2"), line) + assertEquals("10623", postalCode) + assertEquals("Berlin", city) + + ReturnType.Address + }, + processOrganization = { name, address, uniqueIdentifier, phone, mail -> + assertEquals("MVZ", name) + assertEquals(ReturnType.Address, address) + assertEquals("721111100", uniqueIdentifier) + assertEquals("0301234567", phone) + assertEquals("mvz@e-mail.de", mail) + + ReturnType.Organization + } + ) + + assertEquals(ReturnType.Organization, result) + } + + @Test + fun `process patient`() { + val patient = Json.parseToJsonElement(patientJson) + val result = extractPatient( + patient, + processAddress = { line, postalCode, city -> + assertEquals(listOf("Siegburger Str. 155"), line) + assertEquals("51105", postalCode) + assertEquals("Köln", city) + + ReturnType.Address + }, + processPatient = { name, address, birthDate, insuranceIdentifier -> + assertEquals("Prinzessin Lars Graf Freiherr von Schinder", name) + assertEquals(ReturnType.Address, address) + assertEquals(LocalDate.parse("1964-04-04"), birthDate) + assertEquals("X110535541", insuranceIdentifier) + + ReturnType.Patient + } + ) + + assertEquals(ReturnType.Patient, result) + } + + @Test + fun `process practitioner`() { + val practitioner = Json.parseToJsonElement(practitionerJson) + val result = extractPractitioner( + practitioner, + processPractitioner = { name, qualification, practitionerIdentifier -> + assertEquals("Dr. med. Emma Schneider", name) + assertEquals("Fachärztin für Innere Medizin", qualification) + assertEquals("987654423", practitionerIdentifier) + + ReturnType.Practitioner + } + ) + assertEquals(ReturnType.Practitioner, result) + } + + @Test + fun `process insuranceInformation`() { + val insuranceInformation = Json.parseToJsonElement(insuranceInformationJson) + val result = extractInsuranceInformation( + insuranceInformation, + processInsuranceInformation = { name: String?, statusCode: String? -> + assertEquals("HEK", name) + assertEquals("3", statusCode) + + ReturnType.InsuranceInformation + } + ) + assertEquals(ReturnType.InsuranceInformation, result) + } + + @Test + fun `process quantity`() { + val quantityJson = Json.parseToJsonElement(quantityJson) + val result = quantityJson.extractQuantity { value, unit -> + assertEquals("12", value) + assertEquals("TAB", unit) + ReturnType.Quantity + } + assertEquals(ReturnType.Quantity, result) + } + + @Test + fun `process ratio`() { + val ratioJson = Json.parseToJsonElement(ratioJson) + val result = ratioJson.extractRatio( + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + + ReturnType.Ratio + } + ) + assertEquals(ReturnType.Ratio, result) + } + + @Test + fun `process ingredient`() { + val ingredientJson = Json.parseToJsonElement(ingredientJson) + val result = ingredientJson.extractIngredient( + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + ReturnType.Ratio + }, + ingredientFn = { text, form, number, amount, strength -> + assertEquals("Wirkstoff Paulaner Weissbier", text) + assertEquals(null, form) + assertEquals("37197", number) + assertEquals(null, amount) + assertEquals(ReturnType.Ratio, strength) + ReturnType.Ingredient + } + ) + assertEquals(ReturnType.Ingredient, result) + } + + @Test + fun `process multi prescription info`() { + val multiPrescriptionInfoJson = Json.parseToJsonElement(multiPrescriptionInfoJson) + val result = multiPrescriptionInfoJson.extractMultiplePrescriptionInfo( + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + ReturnType.Ratio + }, + processMultiplePrescriptionInfo = { + indicator, numbering, start -> + assertEquals(true, indicator) + assertEquals(ReturnType.Ratio, numbering) + assertEquals(LocalDate.parse("2022-08-17"), start) + ReturnType.MultiplePrescriptionInfo + } + ) + assertEquals(ReturnType.MultiplePrescriptionInfo, result) + } + + @Test + fun `process medicationPzn`() { + val medicationPznJson = Json.parseToJsonElement(medicationPznJson) + val result = extractMedication( + medicationPznJson, + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + ReturnType.Ratio + }, + ingredientFn = { text, form, number, amount, strength -> + assertEquals("Wirkstoff Paulaner Weissbier", text) + assertEquals(null, form) + assertEquals("", number) + assertEquals(null, amount) + assertEquals(ReturnType.Ratio, strength) + ReturnType.Ingredient + }, + processMedication = { + text, medicationProfile, medicationCategory, form, amount, vaccine, + manufacturingInstructions, packaging, normSizeCode, uniqueIdentifier, + ingredients, lotnumber, expirationDate -> + assertEquals("Ich bin in Einlösung", text) + assertEquals(MedicationProfile.PZN, medicationProfile) + assertEquals(MedicationCategory.ARZNEI_UND_VERBAND_MITTEL, medicationCategory) + assertEquals("IHP", form) + assertEquals(ReturnType.Ratio, amount) + assertEquals(false, vaccine) + assertEquals(null, manufacturingInstructions) + assertEquals(null, packaging) + assertEquals("N1", normSizeCode) + assertEquals("00427833", uniqueIdentifier) + assertEquals(listOf(), ingredients) + assertEquals(null, lotnumber) + assertEquals(null, expirationDate) + ReturnType.Medication + } + ) + assertEquals(ReturnType.Medication, result) + } + + @Test + fun `process medication ingredient`() { + val medicationIngredientJson = Json.parseToJsonElement(medicationIngredientJson) + val result = extractMedication( + medicationIngredientJson, + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + ReturnType.Ratio + }, + ingredientFn = { text, form, number, amount, strength -> + assertEquals("Wirkstoff Paulaner Weissbier", text) + assertEquals(null, form) + assertEquals(null, amount) + assertEquals("37197", number) + assertEquals(ReturnType.Ratio, strength) + ReturnType.Ingredient + }, + processMedication = { + text, medicationProfile, medicationCategory, form, amount, vaccine, + manufacturingInstructions, packaging, normSizeCode, uniqueIdentifier, + ingredients, lotNumber, expirationDate -> + assertEquals(null, text) + assertEquals(MedicationProfile.INGREDIENT, medicationProfile) + assertEquals(MedicationCategory.ARZNEI_UND_VERBAND_MITTEL, medicationCategory) + assertEquals("Flüssigkeiten", form) + assertEquals(null, amount) + assertEquals(false, vaccine) + assertEquals(null, manufacturingInstructions) + assertEquals(null, packaging) + assertEquals("N1", normSizeCode) + assertEquals(null, uniqueIdentifier) + assertEquals(listOf(ReturnType.Ingredient), ingredients) + + assertEquals(null, lotNumber) + assertEquals(null, expirationDate) + ReturnType.Medication + } + ) + assertEquals(ReturnType.Medication, result) + } + + @Test + fun `process medication compounding`() { + val medicationCompoundingJson = Json.parseToJsonElement(medicationCompoundingJson) + val result = extractMedication( + medicationCompoundingJson, + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { _, _ -> + ReturnType.Ratio + }, + ingredientFn = { _, _, _, _, strength -> + assertEquals(ReturnType.Ratio, strength) + ReturnType.Ingredient + }, + processMedication = { + text, medicationProfile, medicationCategory, form, amount, vaccine, + manufacturingInstructions, packaging, normSizeCode, uniqueIdentifier, + ingredients, lotNumber, expirationDate -> + assertEquals(null, text) + assertEquals(MedicationProfile.COMPOUNDING, medicationProfile) + assertEquals(MedicationCategory.ARZNEI_UND_VERBAND_MITTEL, medicationCategory) + assertEquals("Lösung", form) + assertEquals(ReturnType.Ratio, amount) + assertEquals(false, vaccine) + assertEquals(null, manufacturingInstructions) + assertEquals(null, packaging) + assertEquals(null, normSizeCode) + assertEquals(null, uniqueIdentifier) + assertEquals(listOf(ReturnType.Ingredient, ReturnType.Ingredient), ingredients) + + assertEquals(null, lotNumber) + assertEquals(null, expirationDate) + ReturnType.Medication + } + ) + assertEquals(ReturnType.Medication, result) + } + + @Test + fun `process medication freetext`() { + val medicationFreetextJson = Json.parseToJsonElement(medicationFreetextJson) + val result = extractMedication( + medicationFreetextJson, + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { _, _ -> + ReturnType.Ratio + }, + ingredientFn = { _, _, _, _, strength -> + assertEquals(ReturnType.Ratio, strength) + ReturnType.Ingredient + }, + processMedication = { + text, medicationProfile, medicationCategory, form, amount, vaccine, + manufacturingInstructions, packaging, normSizeCode, uniqueIdentifier, + ingredients, lotNumber, expirationDate -> + assertEquals("Freitext", text) + assertEquals(MedicationProfile.FREETEXT, medicationProfile) + assertEquals(MedicationCategory.ARZNEI_UND_VERBAND_MITTEL, medicationCategory) + assertEquals(null, form) + assertEquals(null, amount) + assertEquals(false, vaccine) + assertEquals(null, manufacturingInstructions) + assertEquals(null, packaging) + assertEquals(null, normSizeCode) + assertEquals(null, uniqueIdentifier) + assertEquals(listOf(), ingredients) + + assertEquals(null, lotNumber) + assertEquals(null, expirationDate) + ReturnType.Medication + } + ) + assertEquals(ReturnType.Medication, result) + } + + @Test + fun `process medicationRequest`() { + val medicationRequestJson = Json.parseToJsonElement(medicationRequestJson) + val result = extractMedicationRequest( + medicationRequestJson, + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + ReturnType.Ratio + }, + processMultiplePrescriptionInfo = { indicator, numbering, start -> + assertTrue(indicator) + assertEquals(ReturnType.Ratio, numbering) + assertEquals(LocalDate.parse("2022-08-17"), start) + ReturnType.MultiplePrescriptionInfo + }, + processMedicationRequest = { dateOfAccident, location, emergencyFee, substitutionAllowed, dosageInstruction, + multiplePrescriptionInfo, note, bvg, additionalFee -> + assertEquals(LocalDate.parse("2022-06-29"), dateOfAccident) + assertEquals("Dummy-Betrieb", location) + assertEquals(false, emergencyFee) + assertEquals(true, substitutionAllowed) + assertEquals("1-2-1-2-0", dosageInstruction) + assertEquals(ReturnType.MultiplePrescriptionInfo, multiplePrescriptionInfo) + assertEquals("Bitte laengliche Tabletten.", note) + assertEquals(true, bvg) + assertEquals("2", additionalFee) + ReturnType.MedicationRequest + } + ) + assertEquals(ReturnType.MedicationRequest, result) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt new file mode 100644 index 00000000..7aefd801 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/MedicationDispenseMapperTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import kotlinx.serialization.json.Json +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import kotlin.test.assertEquals + +class MedicationDispenseMapperTest { + + @Test + fun `extract medication dispense`() { + val medicationDispenseJson = Json.parseToJsonElement(medicationDispenseJson) + val result = extractMedicationDispense( + medicationDispenseJson, + quantityFn = { _, _ -> + ReturnType.Quantity + }, + ratioFn = { numerator, denominator -> + assertEquals(ReturnType.Quantity, numerator) + assertEquals(ReturnType.Quantity, denominator) + ReturnType.Ratio + }, + ingredientFn = { text, form, number, amount, strength -> + assertEquals("Wirkstoff Paulaner Weissbier", text) + assertEquals(null, form) + assertEquals("", number) + assertEquals(null, amount) + assertEquals(ReturnType.Ratio, strength) + ReturnType.Ingredient + }, + processMedication = { text, medicationProfile, medicationCategory, form, amount, vaccine, + manufacturingInstructions, packaging, normSizeCode, uniqueIdentifier, + ingredients, lotNumber, expirationDate -> + assertEquals("Defamipin", text) + assertEquals(MedicationProfile.PZN, medicationProfile) + assertEquals(MedicationCategory.BTM, medicationCategory) + assertEquals("FET", form) + assertEquals(ReturnType.Ratio, amount) + assertEquals(false, vaccine) + assertEquals(null, manufacturingInstructions) + assertEquals(null, packaging) + assertEquals("Sonstiges", normSizeCode) + assertEquals("06491772", uniqueIdentifier) + assertEquals(listOf(), ingredients) + assertEquals("8521037577", lotNumber) + assertEquals(Instant.parse("2023-05-02T06:26:06Z"), expirationDate) + ReturnType.Medication + }, + processMedicationDispense = { dispenseId, patientIdentifier, medication, wasSubstituted, + dosageInstruction, performer, whenHandedOver -> + assertEquals("160.000.000.031.686.59", dispenseId) + assertEquals("X110535541", patientIdentifier) + assertEquals(ReturnType.Medication, medication) + assertEquals(false, wasSubstituted) + assertEquals(null, dosageInstruction) + assertEquals("3-SMC-B-Testkarte-883110000116873", performer) + assertEquals(LocalDate.parse("2022-07-12"), whenHandedOver) + ReturnType.MedicationDispense + } + ) + assertEquals(ReturnType.MedicationDispense, result) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt new file mode 100644 index 00000000..abfa933b --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import kotlinx.serialization.json.Json +import java.io.File +import java.time.DayOfWeek +import java.time.LocalTime +import kotlin.test.Test +import kotlin.test.assertEquals + +const val ResourceBasePath = "src/commonTest/resources/" + +private val testBundle by lazy { File("$ResourceBasePath/pharmacy_result_bundle.json").readText() } + +class PharmacyMapperTest { + private val openingTimeA = OpeningTime(LocalTime.parse("08:00:00"), LocalTime.parse("12:00:00")) + private val openingTimeB = OpeningTime(LocalTime.parse("14:00:00"), LocalTime.parse("18:00:00")) + private val openingTimeC = OpeningTime(LocalTime.parse("08:00:00"), LocalTime.parse("20:00:00")) + private val expected = Pharmacy( + name = "Heide-Apotheke", + address = PharmacyAddress( + lines = listOf("Langener Landstraße 266"), + postalCode = "27578", + city = "Bremerhaven" + ), + location = Location(latitude = 8.597412, longitude = 53.590027), + contacts = PharmacyContacts( + phone = "0471/87029", + mail = "info@heide-apotheke-bremerhaven.de", + url = "http://www.heide-apotheke-bremerhaven.de" + ), + provides = listOf( + LocalPharmacyService( + name = "Heide-Apotheke", + openingHours = OpeningHours( + openingTime = mapOf( + DayOfWeek.MONDAY to listOf(openingTimeA, openingTimeB), + DayOfWeek.TUESDAY to listOf(openingTimeA, openingTimeB), + DayOfWeek.WEDNESDAY to listOf(openingTimeA, openingTimeB), + DayOfWeek.THURSDAY to listOf(openingTimeA, openingTimeB), + DayOfWeek.FRIDAY to listOf(openingTimeA, openingTimeB), + DayOfWeek.SATURDAY to listOf(openingTimeA) + ) + ) + ), + DeliveryPharmacyService( + name = "Heide-Apotheke", + openingHours = OpeningHours( + openingTime = mapOf( + DayOfWeek.MONDAY to listOf(openingTimeC), + DayOfWeek.TUESDAY to listOf(openingTimeC), + DayOfWeek.WEDNESDAY to listOf(openingTimeC), + DayOfWeek.THURSDAY to listOf(openingTimeC), + DayOfWeek.FRIDAY to listOf(openingTimeC) + ) + ) + ), + OnlinePharmacyService( + name = "Heide-Apotheke" + ), + PickUpPharmacyService( + name = "Heide-Apotheke" + ) + ), + telematikId = "3-05.2.1007600000.080", + ready = true + ) + + @Test + fun `map pharmacies from JSON bundle`() { + val pharmacies = extractPharmacyServices( + Json.parseToJsonElement(testBundle), + onError = { element, cause -> + println(element) + throw cause + } + ).pharmacies + + assertEquals(10, pharmacies.size) + + assertEquals(expected, pharmacies[0]) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TaskMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TaskMapperTest.kt new file mode 100644 index 00000000..f258d7e9 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TaskMapperTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import kotlinx.serialization.json.Json +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import kotlin.test.assertEquals + +class TaskMapperTest { + + @Test + fun `extract task`() { + val taskJson = Json.parseToJsonElement(taskJson) + extractTask( + taskJson, + process = { taskId, accessCode, lastModified, expiresOn, acceptUntil, authoredOn, status -> + assertEquals("160.000.000.029.982.30", taskId) + assertEquals("dd23212d35d14ccde351f9a1077f3d9508dcb8629882627ec16a22ea86144290", accessCode) + assertEquals(Instant.parse("2022-06-09T11:57:37.923Z"), lastModified) + assertEquals(LocalDate.parse("2022-09-09"), expiresOn) + assertEquals(LocalDate.parse("2022-07-07"), acceptUntil) + assertEquals(Instant.parse("2022-06-09T11:50:23.223Z"), authoredOn) + assertEquals(TaskStatus.Completed, status) + } + ) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestDataKBVMapper.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestDataKBVMapper.kt new file mode 100644 index 00000000..ffb4e8f9 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/TestDataKBVMapper.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.model + +import java.io.File + +val taskJson by lazy { + File("$ResourceBasePath/fhir/task.json").readText() +} +val organizationJson by lazy { + File("$ResourceBasePath/fhir/organization.json").readText() +} +val patientJson by lazy { + File("$ResourceBasePath/fhir/patient.json").readText() +} +val practitionerJson by lazy { + File("$ResourceBasePath/fhir/practitioner.json").readText() +} +val insuranceInformationJson by lazy { + File("$ResourceBasePath/fhir/insurance_information.json").readText() +} +val quantityJson by lazy { + File("$ResourceBasePath/fhir/quantity.json").readText() +} +val ratioJson by lazy { + File("$ResourceBasePath/fhir/ratio.json").readText() +} +val ingredientJson by lazy { + File("$ResourceBasePath/fhir/ingredient.json").readText() +} +val multiPrescriptionInfoJson by lazy { + File("$ResourceBasePath/fhir/multi_prescription_info.json").readText() +} +val medicationPznJson by lazy { + File("$ResourceBasePath/fhir/medication_pzn.json").readText() +} +val medicationIngredientJson by lazy { + File("$ResourceBasePath/fhir/medication_ingredient.json").readText() +} +val medicationCompoundingJson by lazy { + File("$ResourceBasePath/fhir/medication_compounding.json").readText() +} +val medicationFreetextJson by lazy { + File("$ResourceBasePath/fhir/medication_freetext.json").readText() +} +val medicationRequestJson by lazy { + File("$ResourceBasePath/fhir/medication_request.json").readText() +} +val medicationDispenseJson by lazy { + File("$ResourceBasePath/fhir/medication_dispense.json").readText() +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ComparatorTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ComparatorTest.kt new file mode 100644 index 00000000..4f20170a --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ComparatorTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ComparatorTest { + @Test + fun `stringValue test`() { + assertTrue(stringValue("test123").invoke(Json.parseToJsonElement("test123"))) + assertTrue(stringValue("test123", ignoreCase = true).invoke(Json.parseToJsonElement("TEST123"))) + + assertFalse(stringValue("test123").invoke(Json.parseToJsonElement("null"))) + assertFalse(stringValue("test123").invoke(Json.parseToJsonElement("{}"))) + } + + @Test + fun `regexValue test`() { + assertTrue(regexValue("""test\d+""".toRegex()).invoke(Json.parseToJsonElement("test123"))) + assertTrue(regexValue("""test\d+""".toRegex()).invoke(Json.parseToJsonElement("test12345678"))) + + assertFalse(regexValue("""test\d+""".toRegex()).invoke(Json.parseToJsonElement("null"))) + assertFalse(regexValue("""test\d+""".toRegex()).invoke(Json.parseToJsonElement("{}"))) + } + + @Test + fun `rangeValue floating point test`() { + assertTrue(rangeValue(0f..1f, String::toFloatOrNull).invoke(Json.parseToJsonElement("0.0004"))) + assertTrue(rangeValue(0f..1f, String::toFloatOrNull).invoke(Json.parseToJsonElement("0.0000"))) + assertTrue(rangeValue(0f..1f, String::toFloatOrNull).invoke(Json.parseToJsonElement("1.0000"))) + + assertFalse(rangeValue(0f..1f, String::toFloatOrNull).invoke(Json.parseToJsonElement("-1.0000"))) + assertFalse(rangeValue(0f..1f, String::toFloatOrNull).invoke(Json.parseToJsonElement("5"))) + } + + private fun toInt10(s: String) = s.toIntOrNull() + + @Test + fun `rangeValue integer test`() { + assertTrue(rangeValue(-44..66, ::toInt10).invoke(Json.parseToJsonElement("0"))) + assertTrue(rangeValue(-44..66, ::toInt10).invoke(Json.parseToJsonElement("66"))) + assertTrue(rangeValue(-44..66, ::toInt10).invoke(Json.parseToJsonElement("-44"))) + assertTrue(rangeValue(-44..66, ::toInt10).invoke(Json.parseToJsonElement("-9"))) + + assertFalse(rangeValue(-44..66, ::toInt10).invoke(Json.parseToJsonElement("0.5"))) + assertFalse(rangeValue(-44..66, ::toInt10).invoke(Json.parseToJsonElement("1000"))) + } + + @Test + fun `profileValue - profile with version`() { + assertTrue( + profileValue("https://base.profile/PROFILE", "1.0.1") + .invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.1")) + ) + assertTrue( + profileValue("https://base.profile/PROFILE", "1.0.1", "1.0.2", "1.0.3") + .invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.3")) + ) + + assertFalse( + profileValue("https://base.profile/PROFILE", "1.0.1", "1.0.2", "1.0.3") + .invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.7")) + ) + assertFalse( + profileValue("https://base.profile/PROFILE") + .invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.7")) + ) + } + + @Test + fun `profileValue - profile without version`() { + assertTrue( + profileValue("https://base.profile/PROFILE") + .invoke(JsonPrimitive("https://base.profile/PROFILE")) + ) + + assertFalse( + profileValue("https://base.profile/PROFILE", "1.0.1", "1.0.2", "1.0.3") + .invoke(JsonPrimitive("https://base.profile/PROFILE")) + ) + assertFalse( + profileValue("https://base.profile/PROFILE", "") + .invoke(JsonPrimitive("https://base.profile/PROFILE")) + ) + } + + @Test + fun `or comparator test`() { + assertTrue( + or( + stringValue("https://base.profile/PROFILE|1.0.3"), + profileValue("https://base.profile/PROFILE", "1.0.1", "1.0.2", "1.0.3") + ).invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.3")) + ) + + assertTrue( + or( + stringValue("https://base.profile/PROFILE"), + profileValue("https://base.profile/PROFILE", "1.0.1", "1.0.2", "1.0.3") + ).invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.3")) + ) + + assertFalse( + or( + stringValue("https://base.profile/"), + profileValue("https://base.profile/PROFILE") + ).invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.3")) + ) + } + + @Test + fun `not comparator test`() { + assertFalse( + not(stringValue("https://base.profile/PROFILE|1.0.3")) + .invoke(JsonPrimitive("https://base.profile/PROFILE|1.0.3")) + ) + + assertTrue( + not(stringValue("abc")) + .invoke(JsonPrimitive("abcd")) + ) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ConverterTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ConverterTest.kt new file mode 100644 index 00000000..7125e949 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ConverterTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.Year +import java.time.YearMonth +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertIs +import kotlin.test.assertNull + +class ConverterTest { + private val fhirInstant = listOf( + "2015-02-07T13:28:17+02:00", + "2015-02-07T13:28:17+00:00", + "2015-02-07T13:28:17.243+00:00", + "2022-01-13T15:44:15.816+00:00" + ) + + private val fhirLocalDate = listOf( + "2015-02-03", + "2011-03-12" + ) + + private val fhirYearMonth = listOf( + "2015-02", + "1999-01" + ) + + private val fhirYear = listOf( + "2015", + "1999" + ) + + private val fhirTime = listOf( + "13:28:00", + "13:28:17" + ) + + @Test + fun `convert dates expecting type`() { + fhirInstant.forEach { + assertIs(JsonPrimitive(it).asTemporalAccessor()) + } + fhirLocalDate.forEach { + assertIs(JsonPrimitive(it).asTemporalAccessor()) + } + fhirYearMonth.forEach { + assertIs(JsonPrimitive(it).asTemporalAccessor()) + } + fhirYear.forEach { + assertIs(JsonPrimitive(it).asTemporalAccessor()) + } + fhirTime.forEach { + assertIs(JsonPrimitive(it).asTemporalAccessor()) + } + } + + @Test + fun `contained primitive - string`() { + val a = Json.parseToJsonElement("""{ "foo": "bar" }""") + val b = Json.parseToJsonElement("""{ "foo": [ "bar" ] }""") + + assertEquals("bar", a.containedString("foo")) + assertEquals("bar", b.containedString("foo")) + + assertEquals("bar", a.jsonObject["foo"]!!.containedString()) + assertEquals("bar", b.jsonObject["foo"]!!.containedString()) + } + + @Test + fun `contained primitive - int`() { + val a = Json.parseToJsonElement("""{ "foo": 1 }""") + val b = Json.parseToJsonElement("""{ "foo": [ 1 ] }""") + + assertEquals(1, a.containedInt("foo")) + assertEquals(1, b.containedInt("foo")) + + assertEquals(1, a.jsonObject["foo"]!!.containedInt()) + assertEquals(1, b.jsonObject["foo"]!!.containedInt()) + } + + @Test + fun `contained primitive - double`() { + val a = Json.parseToJsonElement("""{ "foo": 1.0 }""") + val b = Json.parseToJsonElement("""{ "foo": [ 1.0 ] }""") + + assertEquals(1.0, a.containedDouble("foo")) + assertEquals(1.0, b.containedDouble("foo")) + + assertEquals(1.0, a.jsonObject["foo"]!!.containedDouble()) + assertEquals(1.0, b.jsonObject["foo"]!!.containedDouble()) + } + + @Test + fun `contained primitive - string - nullable`() { + val a = Json.parseToJsonElement("""{ "foo": [] }""") + val b = Json.parseToJsonElement("""{ "foo": {} }""") + + assertNull(a.containedStringOrNull("foo")) + assertNull(b.containedStringOrNull("foo")) + + assertNull(a.jsonObject["foo"]!!.containedStringOrNull()) + assertNull(b.jsonObject["foo"]!!.containedStringOrNull()) + + assertFails { a.containedString("foo") } + assertFails { b.containedString("foo") } + + assertFails { a.jsonObject["foo"]!!.containedString() } + assertFails { b.jsonObject["foo"]!!.containedString() } + } + + @Test + fun `contained primitive - int - nullable`() { + val a = Json.parseToJsonElement("""{ "foo": [] }""") + val b = Json.parseToJsonElement("""{ "foo": {} }""") + + assertNull(a.containedIntOrNull("foo")) + assertNull(b.containedIntOrNull("foo")) + + assertNull(a.jsonObject["foo"]!!.containedIntOrNull()) + assertNull(b.jsonObject["foo"]!!.containedIntOrNull()) + + assertFails { a.containedInt("foo") } + assertFails { b.containedInt("foo") } + + assertFails { a.jsonObject["foo"]!!.containedInt() } + assertFails { b.jsonObject["foo"]!!.containedInt() } + } + + @Test + fun `contained primitive - double - nullable`() { + val a = Json.parseToJsonElement("""{ "foo": [] }""") + val b = Json.parseToJsonElement("""{ "foo": {} }""") + + assertNull(a.containedDoubleOrNull("foo")) + assertNull(b.containedDoubleOrNull("foo")) + + assertNull(a.jsonObject["foo"]!!.containedDoubleOrNull()) + assertNull(b.jsonObject["foo"]!!.containedDoubleOrNull()) + + assertFails { a.containedDouble("foo") } + assertFails { b.containedDouble("foo") } + + assertFails { a.jsonObject["foo"]!!.containedDouble() } + assertFails { b.jsonObject["foo"]!!.containedDouble() } + } + + @Test + fun `contained object`() { + val a = Json.parseToJsonElement("""{ "foo": { "bar": "baz" } }""") + val b = Json.parseToJsonElement("""{ "foo": [ { "bar": "baz" } ] }""") + + val expected = Json.parseToJsonElement("""{ "bar": "baz" }""").toString() + + assertEquals(expected, a.jsonObject["foo"]!!.containedObject().toString()) + assertEquals(expected, b.jsonObject["foo"]!!.containedObject().toString()) + } + + @Test + fun `contained object - nullable`() { + val a = Json.parseToJsonElement("""{ "foo": true }""") + val b = Json.parseToJsonElement("""{ "foo": [] }""") + + assertNull(a.jsonObject["foo"]!!.containedObjectOrNull()) + assertNull(b.jsonObject["foo"]!!.containedObjectOrNull()) + + assertFails { a.jsonObject["foo"]!!.containedObject() } + assertFails { b.jsonObject["foo"]!!.containedObject() } + } + + @Test + fun `contained array`() { + val a = Json.parseToJsonElement("""{ "foo": [ [ { "bar": "baz" } ] ] }""") + val b = Json.parseToJsonElement("""{ "foo": [ { "bar": "baz" } ] }""") + + val expected = Json.parseToJsonElement("""[ { "bar": "baz" } ]""").toString() + + assertEquals(expected, a.containedArray("foo").toString()) + assertEquals(expected, b.containedArray("foo").toString()) + + assertEquals(expected, a.jsonObject["foo"]!!.containedArray().toString()) + assertEquals(expected, b.jsonObject["foo"]!!.containedArray().toString()) + } + + @Test + fun `contained array - nullable`() { + val a = Json.parseToJsonElement("""{ "foo": {} }""") + val b = Json.parseToJsonElement("""{ "foo": true }""") + + assertNull(a.containedArrayOrNull("foo")) + assertNull(b.containedArrayOrNull("foo")) + + assertFails { a.jsonObject["foo"]!!.containedArray() } + assertFails { b.jsonObject["foo"]!!.containedArray() } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/FormatterTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/FormatterTest.kt new file mode 100644 index 00000000..909337f4 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/FormatterTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +class FormatterTest { + + @Test + fun `json object`() { + val input = """ + { + "test1": "someValue", + "test2": [ 1, 2, 3, 4 ], + "test3": { + "test4": "someValue" + } + } + """.trimIndent() + + val expected = """ + { + "test1": null, + "test2": [ null, null, null, null ], + "test3": { + "test4": null + } + } + """.trimIndent().replace("\\s+".toRegex(), "") + + assertEquals( + expected, + Json.encodeToString(JsonPrimitiveAsNullSerializer, Json.parseToJsonElement(input)) + ) + } + + @Test + fun `json array`() { + val input = """ + [ + { "test1": "someValue" }, + { "test2": "someValue" }, + { "test3": "someValue" }, + 1 + ] + """.trimIndent() + + val expected = """ + [ + { "test1": null }, + { "test2": null }, + { "test3": null }, + null + ] + """.trimIndent().replace("\\s+".toRegex(), "") + + assertEquals( + expected, + Json.encodeToString(JsonPrimitiveAsNullSerializer, Json.parseToJsonElement(input)) + ) + } + + @Test + fun `json primitive`() { + val input = """ + 123456 + """.trimIndent() + + val expected = """ + null + """.trimIndent().replace("\\s+".toRegex(), "") + + assertEquals( + expected, + Json.encodeToString(JsonPrimitiveAsNullSerializer, Json.parseToJsonElement(input)) + ) + } + + @Test + fun `transform all string values with another string`() { + val input = """ + { + "test1": "someValue", + "test2": [ 1, 2, 3, 4 ], + "test3": { + "test4": "someValue" + } + } + """.trimIndent() + + val expected = """ + { + "test1": "otherValue", + "test2": [ 1, 2, 3, 4 ], + "test3": { + "test4": "otherValue" + } + } + """.trimIndent() + + assertEquals( + Json.parseToJsonElement(expected), + Json.parseToJsonElement(input).transformValues { + if (it.isString) { + JsonPrimitive("otherValue") + } else { + it + } + } + ) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ParserTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ParserTest.kt new file mode 100644 index 00000000..7509d484 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ParserTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertIs + +class ParserTest { + private val jsonBundle: JsonElement + get() = Json.decodeFromString(testBundle) + + @Test + fun `find the name of the patient resource matching the given profile `() { + val result = jsonBundle + .findAll("entry.resource.entry.resource") + .filterWith( + "meta.profile", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient|1.0.3") + ) + .findAll("name") + .toList() + + assertEquals(1, result.size) + assertIs(result.first()) + assertEquals("Graf Freiherr von Schaumberg", (result.first() as JsonObject)["family"]!!.containedString()) + assertEquals("Karl-Friederich", (result.first() as JsonObject)["given"]!!.containedString()) + } + + @Test + fun `find all resources within the bundle`() { + val result = jsonBundle + .findAll("entry.resource") + .filterWith( + "meta.profile", + stringValue("https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle|1.0.2") + ) + .findAll("entry.resource.meta.profile") + .toList() + + assertEquals(7, result.size) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Composition|1.0.2", + result[0].containedString() + ) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Prescription|1.0.2", + result[1].containedString() + ) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN|1.0.2", + result[2].containedString() + ) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient|1.0.3", + result[3].containedString() + ) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Practitioner|1.0.3", + result[4].containedString() + ) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Organization|1.0.3", + result[5].containedString() + ) + assertEquals( + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Coverage|1.0.3", + result[6].containedString() + ) + } + + @Test + fun `find entry to primary resource`() { + val result = jsonBundle + .findAll("") + .toList() + + assertEquals(1, result.size) + assertEquals( + "collection", + (result.first() as JsonObject)["type"]!!.containedString() + ) + } + + @Test + fun `base path with trailing dot throws exception`() { + assertFails { + jsonBundle + .findAll("entry.") + .toList() + } + } + + @Test + fun `base path with dots throws exception`() { + assertFails { + jsonBundle + .findAll("..") + .toList() + } + assertFails { + jsonBundle + .findAll(".") + .toList() + } + } + + @Test + fun `base path leading dot throws exception`() { + assertFails { + jsonBundle + .findAll(".entry") + .toList() + } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/TestData.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/TestData.kt new file mode 100644 index 00000000..39a62292 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/TestData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.fhir.parser + +import java.io.File + +const val ResourceBasePath = "src/commonTest/resources/" + +val testBundle by lazy { File("$ResourceBasePath/pharmacy_parser_bundle.json").readText() } diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/api/models/BasicDataTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/api/models/BasicDataTest.kt new file mode 100644 index 00000000..c72f9340 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/api/models/BasicDataTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.api.models + +import org.junit.Assert +import org.junit.Test + +class BasicDataTest { + + @Test + fun `generateRandomUrlSafeStringSecure - expected length works`() { + Assert.assertEquals(1, generateRandomUrlSafeStringSecure(1).length) + Assert.assertEquals(32, generateRandomUrlSafeStringSecure(32).length) + Assert.assertEquals(64, generateRandomUrlSafeStringSecure(64).length) + Assert.assertEquals(77, generateRandomUrlSafeStringSecure(77).length) + Assert.assertEquals(111, generateRandomUrlSafeStringSecure(111).length) + Assert.assertEquals(12345, generateRandomUrlSafeStringSecure(12345).length) + } + + @Test + fun `generateRandomUrlSafeStringSecure - base 64 url safe charset only`() { + Assert.assertTrue("""^[A-Za-z0-9_-]+$""".toRegex().matches(generateRandomUrlSafeStringSecure(12345))) + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt new file mode 100644 index 00000000..5bf8c66c --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRepositoryTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.repository + +import de.gematik.ti.erp.app.BCProvider +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.ACTUAL_SCHEMA_VERSION +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.AuditEventEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpAuthenticationDataEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpConfigurationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PasswordEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import de.gematik.ti.erp.app.fhir.model.ResourceBasePath +import de.gematik.ti.erp.app.idp.EllipticCurvesExtending +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.bouncycastle.cert.X509CertificateHolder +import org.jose4j.base64url.Base64 +import org.jose4j.jws.JsonWebSignature +import org.jose4j.jwx.JsonWebStructure +import org.junit.Rule +import java.io.File +import java.security.Security +import java.time.Instant +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +const val EXPECTED_EXPIRATION_TIME = 1616143876L +const val EXPECTED_ISSUE_TIME = 1616057476L + +@OptIn(ExperimentalCoroutinesApi::class) +class CommonIdpRepositoryTest : TestDB() { + + init { + EllipticCurvesExtending.init() + Security.insertProviderAt(BCProvider, 1) + } + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val defaultProfileName1 = "TestProfile" + + private val profileId = "12345" + private val accessToken = "54321" + + lateinit var realm: Realm + + lateinit var repo: IdpRepository + private val testDiscoveryDocument by lazy { File("$ResourceBasePath/idp/discovery-doc.jwt").readText() } + private val testCertificateDocument by lazy { File("$ResourceBasePath/idp/idpCertificate.txt").readText() } + private val ssoToken by lazy { File("$ResourceBasePath/idp/sso-token.txt").readText() } + private val healthCardCert = X509CertificateHolder(Base64.decode(BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE)) + // private val healthCardCertPrivateKey = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY + + private val x509Certificate = X509CertificateHolder(Base64.decode(testCertificateDocument)) + + private val testIdpConfig = IdpData.IdpConfiguration( + authorizationEndpoint = "http://localhost:8888/sign_response", + ssoEndpoint = "http://localhost:8888/sso_response", + tokenEndpoint = "http://localhost:8888/token", + pairingEndpoint = "http://localhost:8888/pairings", + authenticationEndpoint = "http://localhost:8888/alt_response", + pukIdpEncEndpoint = "http://localhost:8888/idpEnc/jwk.json", + pukIdpSigEndpoint = "http://localhost:8888/ipdSig/jwk.json", + certificate = x509Certificate, + expirationTimestamp = Instant.ofEpochSecond(EXPECTED_EXPIRATION_TIME), + issueTimestamp = Instant.ofEpochSecond(EXPECTED_ISSUE_TIME), + externalAuthorizationIDsEndpoint = "http://localhost:8888/appList", + thirdPartyAuthorizationEndpoint = "http://localhost:8888/thirdPartyAuth" + ) + + @MockK + lateinit var remoteDataSource: IdpRemoteDataSource + + lateinit var idpLocalDataSource: IdpLocalDataSource + lateinit var profileRepository: ProfilesRepository + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this) + realm = Realm.open( + RealmConfiguration.Builder( + schema = setOf( + ProfileEntityV1::class, + SyncedTaskEntityV1::class, + OrganizationEntityV1::class, + PractitionerEntityV1::class, + PatientEntityV1::class, + InsuranceInformationEntityV1::class, + MedicationRequestEntityV1::class, + MedicationDispenseEntityV1::class, + CommunicationEntityV1::class, + AddressEntityV1::class, + MedicationEntityV1::class, + IngredientEntityV1::class, + RatioEntityV1::class, + QuantityEntityV1::class, + ScannedTaskEntityV1::class, + IdpAuthenticationDataEntityV1::class, + IdpConfigurationEntityV1::class, + AuditEventEntityV1::class, + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + ShippingContactEntityV1::class, + PharmacySearchEntityV1::class, + MultiplePrescriptionInfoEntityV1::class + ) + ) + .schemaVersion(ACTUAL_SCHEMA_VERSION) + .directory(tempDBPath) + .build() + ) + + idpLocalDataSource = IdpLocalDataSource(realm) + + repo = IdpRepository( + remoteDataSource = remoteDataSource, + localDataSource = idpLocalDataSource + ) + + profileRepository = ProfilesRepository( + dispatchers = coroutineRule.dispatchers, + realm = realm + ) + } + + @Test + fun `save and get access token`() = runTest { + repo.saveDecryptedAccessToken(profileId, accessToken) + assertEquals(accessToken, repo.decryptedAccessToken(profileId).first()) + } + + @Test + fun `save and get single signOn token`() = runTest { + val ssoToken = IdpData.DefaultToken( + token = IdpData.SingleSignOnToken( + token = ssoToken + ), + "123123", + healthCardCert + ) + + profileRepository.saveProfile(defaultProfileName1, true) + val testprofile = + profileRepository.profiles().first()[0] + repo.saveSingleSignOnToken(testprofile.id, ssoToken) + + val savedSsoToken = profileRepository.profiles().first()[0].singleSignOnTokenScope + assertEquals(ssoToken, savedSsoToken) + } + + @Test + fun `load unchecked idp configuration`() { + val discoveryDocument = JWSDiscoveryDocument( + JsonWebStructure.fromCompactSerialization( + testDiscoveryDocument + ) as JsonWebSignature + ) + + coEvery { remoteDataSource.fetchDiscoveryDocument() } coAnswers { Result.success(discoveryDocument) } + runTest { + val idpConfiguration = repo.loadUncheckedIdpConfiguration() + + assertEquals(testIdpConfig.authorizationEndpoint, idpConfiguration.authorizationEndpoint) + assertEquals(testIdpConfig.ssoEndpoint, idpConfiguration.ssoEndpoint) + assertEquals(testIdpConfig.tokenEndpoint, idpConfiguration.tokenEndpoint) + assertEquals(testIdpConfig.pairingEndpoint, idpConfiguration.pairingEndpoint) + assertEquals(testIdpConfig.authenticationEndpoint, idpConfiguration.authenticationEndpoint) + assertEquals(testIdpConfig.pukIdpEncEndpoint, idpConfiguration.pukIdpEncEndpoint) + assertEquals(testIdpConfig.pukIdpSigEndpoint, idpConfiguration.pukIdpSigEndpoint) + assertEquals(testIdpConfig.certificate, idpConfiguration.certificate) + assertEquals(testIdpConfig.expirationTimestamp, idpConfiguration.expirationTimestamp) + assertEquals(testIdpConfig.issueTimestamp, idpConfiguration.issueTimestamp) + assertEquals( + testIdpConfig.externalAuthorizationIDsEndpoint, + idpConfiguration.externalAuthorizationIDsEndpoint + ) + assertEquals( + testIdpConfig.thirdPartyAuthorizationEndpoint, + idpConfiguration.thirdPartyAuthorizationEndpoint + ) + + val savedIdpConfig = idpLocalDataSource.loadIdpInfo() + assertEquals(testIdpConfig, savedIdpConfig) + } + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCaseTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCaseTest.kt similarity index 80% rename from android/src/test/java/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCaseTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCaseTest.kt index f40b0a9b..8879d089 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCaseTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCaseTest.kt @@ -18,10 +18,9 @@ package de.gematik.ti.erp.app.idp.usecase -import de.gematik.ti.erp.app.db.entities.IdpConfiguration +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.idp.model.IdpData import de.gematik.ti.erp.app.idp.repository.IdpRepository -import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository -import de.gematik.ti.erp.app.utils.CoroutineTestRule import de.gematik.ti.erp.app.vau.usecase.TruststoreUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -31,8 +30,7 @@ import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.spyk import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Assert.assertEquals +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule @@ -48,16 +46,13 @@ class IdpBasicUseCaseTest { @MockK private lateinit var idpRepository: IdpRepository - @MockK - private lateinit var profilesRepository: ProfilesRepository - @MockK private lateinit var truststoreUseCase: TruststoreUseCase private lateinit var useCase: IdpBasicUseCase private val now = Instant.now() - private val idpConfigNow = IdpConfiguration( + private val idpConfigNow = IdpData.IdpConfiguration( authorizationEndpoint = "", ssoEndpoint = "", tokenEndpoint = "", @@ -79,8 +74,7 @@ class IdpBasicUseCaseTest { useCase = spyk( IdpBasicUseCase( repository = idpRepository, - truststoreUseCase = truststoreUseCase, - profilesRepository = profilesRepository + truststoreUseCase = truststoreUseCase ) ) @@ -88,7 +82,7 @@ class IdpBasicUseCaseTest { } @Test - fun `checkIdpConfigurationValidity - valid document`() = coroutineRule.testDispatcher.runBlockingTest { + fun `checkIdpConfigurationValidity - valid document`() = runTest { useCase.checkIdpConfigurationValidity( idpConfigNow, now @@ -96,7 +90,7 @@ class IdpBasicUseCaseTest { } @Test(expected = Exception::class) - fun `checkIdpConfigurationValidity - document expired - throws exception`() = coroutineRule.testDispatcher.runBlockingTest { + fun `checkIdpConfigurationValidity - document expired - throws exception`() = runTest { useCase.checkIdpConfigurationValidity( idpConfigNow, now.plus(Duration.ofHours(25)) // account for clock skew @@ -104,7 +98,7 @@ class IdpBasicUseCaseTest { } @Test(expected = Exception::class) - fun `checkIdpConfigurationValidity - document expires too late - throws exception`() = coroutineRule.testDispatcher.runBlockingTest { + fun `checkIdpConfigurationValidity - document expires too late - throws exception`() = runTest { useCase.checkIdpConfigurationValidity( idpConfigNow.copy( expirationTimestamp = now.plus(Duration.ofHours(25)) @@ -118,23 +112,8 @@ class IdpBasicUseCaseTest { assertTrue(useCase.generateCodeVerifier().length in 43..128) } - @Test - fun `generateRandomUrlSafeStringSecure - expected length works`() { - assertEquals(1, generateRandomUrlSafeStringSecure(1).length) - assertEquals(32, generateRandomUrlSafeStringSecure(32).length) - assertEquals(64, generateRandomUrlSafeStringSecure(64).length) - assertEquals(77, generateRandomUrlSafeStringSecure(77).length) - assertEquals(111, generateRandomUrlSafeStringSecure(111).length) - assertEquals(12345, generateRandomUrlSafeStringSecure(12345).length) - } - - @Test - fun `generateRandomUrlSafeStringSecure - base 64 url safe charset only`() { - assertTrue("""^[A-Za-z0-9_-]+$""".toRegex().matches(generateRandomUrlSafeStringSecure(12345))) - } - @Test(expected = Exception::class) - fun `initializeConfigurationAndKeys - invalid idp config causes exception`() = coroutineRule.testDispatcher.runBlockingTest { + fun `initializeConfigurationAndKeys - invalid idp config causes exception`() = runTest { coEvery { idpRepository.loadUncheckedIdpConfiguration() } returns idpConfigNow coEvery { idpRepository.invalidateConfig() } coAnswers {} coEvery { useCase.checkIdpConfigurationValidity(any(), any()) } coAnswers { error("") } @@ -157,7 +136,7 @@ class IdpBasicUseCaseTest { } @Test - fun `initializeConfigurationAndKeys - invalid local idp config - reload idp config`() = coroutineRule.testDispatcher.runBlockingTest { + fun `initializeConfigurationAndKeys - invalid local idp config - reload idp config`() = runTest { val expected = Exception() coEvery { idpRepository.loadUncheckedIdpConfiguration() } returns idpConfigNow @@ -185,7 +164,7 @@ class IdpBasicUseCaseTest { } @Test - fun `initializeConfigurationAndKeys - valid idp config`() = coroutineRule.testDispatcher.runBlockingTest { + fun `initializeConfigurationAndKeys - valid idp config`() = runTest { val expected = Exception() coEvery { idpRepository.loadUncheckedIdpConfiguration() } returns idpConfigNow diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt new file mode 100644 index 00000000..0db75dec --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import de.gematik.ti.erp.app.BCProvider +import de.gematik.ti.erp.app.BuildKonfig +import de.gematik.ti.erp.app.di.JWSConverterFactory +import de.gematik.ti.erp.app.idp.api.IdpService +import de.gematik.ti.erp.app.idp.api.models.IdpScope +import de.gematik.ti.erp.app.idp.model.IdpData +import de.gematik.ti.erp.app.idp.repository.IdpLocalDataSource +import de.gematik.ti.erp.app.idp.repository.IdpPairingRepository +import de.gematik.ti.erp.app.idp.repository.IdpRemoteDataSource +import de.gematik.ti.erp.app.idp.repository.IdpRepository +import de.gematik.ti.erp.app.profiles.repository.ProfilesRepository +import de.gematik.ti.erp.app.vau.usecase.TruststoreUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.util.encoders.Base64 +import org.jose4j.base64url.Base64Url +import org.jose4j.jws.EcdsaUsingShaAlgorithm +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class IdpIntegrationTest { + @MockK(relaxed = true) + private lateinit var profilesRepository: ProfilesRepository + + @MockK + private lateinit var truststoreUseCase: TruststoreUseCase + + @MockK(relaxed = true) + private lateinit var localDataSource: IdpLocalDataSource + + @MockK + private lateinit var cryptoProvider: IdpCryptoProvider + + private lateinit var idpRepository: IdpRepository + private lateinit var idpPairingRepository: IdpPairingRepository + private lateinit var basicUseCase: IdpBasicUseCase + private lateinit var useCase: IdpUseCase + + private val healthCardCert = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE + private val healthCardCertPrivateKey = BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY + + private val profileId = "" + private val cardAccessNumber = "" + + @Suppress("JSON_FORMAT_REDUNDANT") + @OptIn(ExperimentalSerializationApi::class) + private val jsonConverterFactory = Json { + ignoreUnknownKeys = true + encodeDefaults = true + }.asConverterFactory("application/json".toMediaType()) + + @Before + fun setup() { + Assume.assumeTrue(BuildKonfig.TEST_RUN_WITH_IDP_INTEGRATION) + + MockKAnnotations.init(this) + + coEvery { truststoreUseCase.checkIdpCertificate(any(), any()) } coAnswers {} + every { cryptoProvider.signatureInstance() } returns Signature.getInstance("SHA256withECDSA") + coEvery { localDataSource.loadIdpInfo() } returns null + + val client = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().also { + if (BuildKonfig.INTERNAL) it.setLevel(HttpLoggingInterceptor.Level.BODY) + } + ) + .followRedirects(false) + .build() + + val idpService = Retrofit.Builder() + .client(client) + .baseUrl(BuildKonfig.IDP_SERVICE_URI) + .addConverterFactory(JWSConverterFactory()) + .addConverterFactory(jsonConverterFactory) + .build() + .create(IdpService::class.java) + + idpRepository = spyk( + IdpRepository( + remoteDataSource = IdpRemoteDataSource(idpService), + localDataSource = localDataSource + ) + ) + + idpPairingRepository = spyk( + IdpPairingRepository( + localDataSource = localDataSource + ) + ) + + basicUseCase = IdpBasicUseCase( + repository = idpRepository, + truststoreUseCase = truststoreUseCase + ) + + useCase = IdpUseCase( + repository = idpRepository, + pairingRepository = idpPairingRepository, + altAuthUseCase = IdpAlternateAuthenticationUseCase( + basicUseCase = basicUseCase, + repository = idpRepository, + deviceInfo = mockk { + every { deviceName } returns "Test" + every { manufacturer } returns "Test" + every { productName } returns "Test" + every { model } returns "Test" + every { operatingSystem } returns "Android" + every { operatingSystemVersion } returns "XX" + } + ), + profilesRepository = profilesRepository, + basicUseCase = basicUseCase, + preferences = mockk(relaxed = true), + cryptoProvider = cryptoProvider + ) + } + + private fun sign(hash: ByteArray): ByteArray { + val curveSpec = ECNamedCurveTable.getParameterSpec("brainpoolP256r1") + val keySpec = + org.bouncycastle.jce.spec.ECPrivateKeySpec( + BigInteger(Base64.decode(healthCardCertPrivateKey)), + curveSpec + ) + val privateKey = KeyFactory.getInstance("EC", BCProvider).generatePrivate(keySpec) + val signed = Signature.getInstance("NoneWithECDSA").apply { + initSign(privateKey) + update(hash) + }.sign() + return EcdsaUsingShaAlgorithm.convertDerToConcatenated(signed, 64) + } + + @Test + fun `authenticate with health card`() = runTest { + useCase.authenticationFlowWithHealthCard( + profileId = profileId, + cardAccessNumber = cardAccessNumber, + healthCardCertificate = { + Base64.decode(healthCardCert) + }, + sign = { sign(it) } + ) + + coVerify(exactly = 1) { idpRepository.saveSingleSignOnToken(profileId, any()) } + coVerify(exactly = 1) { idpRepository.saveDecryptedAccessToken(profileId, any()) } + + assertEquals(true, idpRepository.decryptedAccessToken(profileId).first()?.isNotEmpty()) + } + + @Test + fun `authenticate with health card and get paired devices`() = runTest { + useCase.authenticationFlowWithHealthCard( + profileId = profileId, + scope = IdpScope.BiometricPairing, + cardAccessNumber = cardAccessNumber, + healthCardCertificate = { + Base64.decode(healthCardCert) + }, + sign = { sign(it) } + ) + + coEvery { localDataSource.authenticationData(profileId) } answers { + flowOf( + IdpData.AuthenticationData( + IdpData.DefaultToken( + token = mockk(relaxed = true), + cardAccessNumber = cardAccessNumber, + healthCardCertificate = Base64.decode(healthCardCert) + ) + ) + ) + } + + val pairedDevices = useCase.getPairedDevices(profileId = profileId) + + println(pairedDevices.getOrThrow()) + } + + @Test + fun `authenticate with key store and get paired devices`() = runTest { + val keyPair = KeyPairGenerator.getInstance("EC") + .apply { initialize(ECGenParameterSpec("secp256r1")) } + .generateKeyPair() + + val keyStore = mockk(relaxed = true) { + every { getEntry(any(), any()) } answers { + mockk { + every { privateKey } returns keyPair.private + } + } + } + + val alias = ByteArray(32).apply { + Random.nextBytes(this) + } + + every { cryptoProvider.keyStoreInstance() } returns keyStore + + useCase.alternatePairingFlowWithSecureElement( + profileId = profileId, + cardAccessNumber = cardAccessNumber, + publicKeyOfSecureElementEntry = keyPair.public, + aliasOfSecureElementEntry = alias, + healthCardCertificate = { + Base64.decode(healthCardCert) + }, + signWithHealthCard = { sign(it) } + ) + + coEvery { idpRepository.authenticationData(profileId) } answers { + flowOf( + IdpData.AuthenticationData( + IdpData.AlternateAuthenticationWithoutToken( + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = alias, + healthCardCertificate = Base64.decode(healthCardCert) + ) + ) + ) + } + + useCase.alternateAuthenticationFlowWithSecureElement(profileId = profileId, scope = IdpScope.Default) + + coVerify(exactly = 2) { idpRepository.saveSingleSignOnToken(profileId, any()) } + coVerify(exactly = 1) { idpRepository.saveDecryptedAccessToken(profileId, any()) } + + assertEquals(true, idpRepository.decryptedAccessToken(profileId).first()?.isNotEmpty()) + + // + // paired devices + // + + useCase.alternateAuthenticationFlowWithSecureElement(profileId = profileId, scope = IdpScope.BiometricPairing) + + coEvery { localDataSource.authenticationData(profileId) } answers { + flowOf( + IdpData.AuthenticationData( + IdpData.AlternateAuthenticationWithoutToken( + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = alias, + healthCardCertificate = Base64.decode(healthCardCert) + ) + ) + ) + } + + val aliasBase64 = Base64Url.encode(alias) + + useCase.getPairedDevices(profileId = profileId).getOrThrow().let { pairedDevices -> + println(pairedDevices) + assertTrue { + pairedDevices.any { (_, pairing) -> + pairing.keyAliasOfSecureElement == aliasBase64 + } + } + } + + useCase.deletePairedDevice(profileId = profileId, deviceAlias = aliasBase64) + + useCase.getPairedDevices(profileId = profileId).getOrThrow().let { pairedDevices -> + println(pairedDevices) + assertFalse { + pairedDevices.any { (_, pairing) -> + pairing.keyAliasOfSecureElement == aliasBase64 + } + } + } + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/CardUtilitiesTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/CardUtilitiesTest.kt similarity index 72% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/CardUtilitiesTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/CardUtilitiesTest.kt index 469c1875..e752353f 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/CardUtilitiesTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/CardUtilitiesTest.kt @@ -16,20 +16,21 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc +package de.gematik.ti.erp.app.nfc -import de.gematik.ti.erp.app.cardwall.model.nfc.CardUtilities.byteArrayToECPoint -import okio.ByteString.Companion.decodeHex +import de.gematik.ti.erp.app.card.model.CardUtilities +import de.gematik.ti.erp.app.card.model.CardUtilities.byteArrayToECPoint import org.bouncycastle.jce.ECNamedCurveTable import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec import org.bouncycastle.math.ec.ECCurve import org.bouncycastle.math.ec.ECPoint +import org.bouncycastle.util.encoders.Hex import org.junit.Assert -import org.junit.Test +import kotlin.test.Test import java.io.IOException class CardUtilitiesTest { - private val byteArray: ByteArray = "044E2778F6AAEF54CB42865A3C30C753495AF4E53121400802D0AB1ACD665E9C774C2FAE1687E9DAA36C64570C909F93176F01EEAFCB45F9C08E49805F127D94EF".decodeHex().toByteArray() + private val byteArray: ByteArray = Hex.decode("044E2778F6AAEF54CB42865A3C30C753495AF4E53121400802D0AB1ACD665E9C774C2FAE1687E9DAA36C64570C909F93176F01EEAFCB45F9C08E49805F127D94EF") private val ecNamedCurveParameterSpec: ECNamedCurveParameterSpec = ECNamedCurveTable.getParameterSpec("BrainpoolP256r1") private val expectedECPoint = @@ -46,9 +47,9 @@ class CardUtilitiesTest { @Throws(IOException::class) fun shouldEncodeAsn1KeyObject() { val asn1InputArray: ByteArray = - "7C438341041B05278F276BD92E6B0EE3478BD3A93B03FE8E4C35556F0D6C13C89C504F91C065E85C1D289B306F61BE2CECCED4E7532BF0925A4907F246DF7A69C8D69ED24F".decodeHex().toByteArray() + Hex.decode("7C438341041B05278F276BD92E6B0EE3478BD3A93B03FE8E4C35556F0D6C13C89C504F91C065E85C1D289B306F61BE2CECCED4E7532BF0925A4907F246DF7A69C8D69ED24F") val expectedKeyArray: ByteArray = - "041B05278F276BD92E6B0EE3478BD3A93B03FE8E4C35556F0D6C13C89C504F91C065E85C1D289B306F61BE2CECCED4E7532BF0925A4907F246DF7A69C8D69ED24F".decodeHex().toByteArray() + Hex.decode("041B05278F276BD92E6B0EE3478BD3A93B03FE8E4C35556F0D6C13C89C504F91C065E85C1D289B306F61BE2CECCED4E7532BF0925A4907F246DF7A69C8D69ED24F") val keyArray: ByteArray = CardUtilities.extractKeyObjectEncoded(asn1InputArray) Assert.assertArrayEquals(expectedKeyArray, keyArray) } diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/KeyDerivationFunctionTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/KeyDerivationFunctionTest.kt similarity index 67% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/KeyDerivationFunctionTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/KeyDerivationFunctionTest.kt index 991c633e..2d856d36 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/KeyDerivationFunctionTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/KeyDerivationFunctionTest.kt @@ -16,35 +16,35 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc +package de.gematik.ti.erp.app.nfc -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.KeyDerivationFunction -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.KeyDerivationFunction.getAES128Key -import okio.ByteString.Companion.decodeHex +import de.gematik.ti.erp.app.card.model.exchange.KeyDerivationFunction +import de.gematik.ti.erp.app.card.model.exchange.KeyDerivationFunction.getAES128Key +import org.bouncycastle.util.encoders.Hex import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class KeyDerivationFunctionTest { private val secretK: ByteArray = - "2ECA74E72CD6C1E0DA235093569984987C34A9F4D34E4E60FB0AD87B983CDC62".decodeHex().toByteArray() + Hex.decode("2ECA74E72CD6C1E0DA235093569984987C34A9F4D34E4E60FB0AD87B983CDC62") @Test fun shouldReturnValidAES128KeyModeEnc() { - val validAes128Key: ByteArray = "AB5541629D18E5F33EE2B13DBDCDBE84".decodeHex().toByteArray() + val validAes128Key: ByteArray = Hex.decode("AB5541629D18E5F33EE2B13DBDCDBE84") val aes128Key = getAES128Key(secretK, KeyDerivationFunction.Mode.ENC) Assert.assertArrayEquals(aes128Key, validAes128Key) } @Test fun shouldReturnValidAES128KeyModeMac() { - val validAes128Key: ByteArray = "E13D3757C7D9073794A3D7CA94B22D30".decodeHex().toByteArray() + val validAes128Key: ByteArray = Hex.decode("E13D3757C7D9073794A3D7CA94B22D30") val aes128Key = getAES128Key(secretK, KeyDerivationFunction.Mode.MAC) Assert.assertArrayEquals(aes128Key, validAes128Key) } @Test fun shouldReturnValidAES128KeyModePassword() { - val validAes128Key: ByteArray = "74C1F5E712B53BAAA3B02B182E0961B9".decodeHex().toByteArray() + val validAes128Key: ByteArray = Hex.decode("74C1F5E712B53BAAA3B02B182E0961B9") val aes128Key = getAES128Key(secretK, KeyDerivationFunction.Mode.PASSWORD) Assert.assertArrayEquals(aes128Key, validAes128Key) } diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/PaceInfoTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/PaceInfoTest.kt similarity index 75% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/PaceInfoTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/PaceInfoTest.kt index 3b04a903..1aaa240c 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/PaceInfoTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/PaceInfoTest.kt @@ -16,20 +16,20 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc +package de.gematik.ti.erp.app.nfc -import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.PaceInfo -import okio.ByteString.Companion.decodeHex +import de.gematik.ti.erp.app.card.model.exchange.PaceInfo +import org.bouncycastle.util.encoders.Hex import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class PaceInfoTest { @Test fun testPaceInfoExtraction() { - val cardAccessBytes: ByteArray = "31143012060A04007F0007020204020202010202010D".decodeHex().toByteArray() + val cardAccessBytes: ByteArray = Hex.decode("31143012060A04007F0007020204020202010202010D") val expectedProtocolId = "0.4.0.127.0.7.2.2.4.2.2" - val expectedPaceInfoProtocolBytes: ByteArray = "04007F00070202040202".decodeHex().toByteArray() + val expectedPaceInfoProtocolBytes: ByteArray = Hex.decode("04007F00070202040202") val paceInfo = PaceInfo(cardAccessBytes) val protocolId = paceInfo.protocolID Assert.assertEquals(expectedProtocolId, protocolId) diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/SecureMessagingTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/SecureMessagingTest.kt similarity index 70% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/SecureMessagingTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/SecureMessagingTest.kt index 6ed26eb7..edc71727 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/SecureMessagingTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/SecureMessagingTest.kt @@ -16,19 +16,19 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc +package de.gematik.ti.erp.app.nfc -import de.gematik.ti.erp.app.cardwall.model.nfc.card.PaceKey -import de.gematik.ti.erp.app.cardwall.model.nfc.card.SecureMessaging -import de.gematik.ti.erp.app.cardwall.model.nfc.command.CommandApdu -import de.gematik.ti.erp.app.cardwall.model.nfc.command.ResponseApdu -import okio.ByteString.Companion.decodeHex +import de.gematik.ti.erp.app.card.model.card.PaceKey +import de.gematik.ti.erp.app.card.model.card.SecureMessaging +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu +import org.bouncycastle.util.encoders.Hex import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class SecureMessagingTest { - private val keyEnc: ByteArray = "68406B4162100563D9C901A6154D2901".decodeHex().toByteArray() - private val keyMac: ByteArray = "73FF268784F72AF833FDC9464049AFC9".decodeHex().toByteArray() + private val keyEnc: ByteArray = Hex.decode("68406B4162100563D9C901A6154D2901") + private val keyMac: ByteArray = Hex.decode("73FF268784F72AF833FDC9464049AFC9") private val paceKey = PaceKey(keyEnc, keyMac) private val secureMessaging = SecureMessaging(paceKey) @@ -36,7 +36,7 @@ class SecureMessagingTest { @Test fun testEncryptionCase1() { val commandApdu = CommandApdu.ofOptions(0x01, 0x02, 0x03, 0x04, null) - val expectedEncryptedApdu = "0D0203040A8E08D92B4FDDC2BBED8C00".decodeHex().toByteArray() + val expectedEncryptedApdu = Hex.decode("0D0203040A8E08D92B4FDDC2BBED8C00") val encryptedCommandApdu = secureMessaging.encrypt(commandApdu) Assert.assertArrayEquals( expectedEncryptedApdu, @@ -55,7 +55,7 @@ class SecureMessagingTest { fun testEncryptionCase2s() { val secureMessaging = SecureMessaging(paceKey) val commandApdu = CommandApdu.ofOptions(0x01, 0x02, 0x03, 0x04, 127) - val expectedEncryptedApdu = "0D02030400000D97017F8E0871D8E0418DAE20F30000".decodeHex().toByteArray() + val expectedEncryptedApdu = Hex.decode("0D02030400000D97017F8E0871D8E0418DAE20F30000") val encryptedCommandApdu = secureMessaging.encrypt(commandApdu) Assert.assertArrayEquals( expectedEncryptedApdu, @@ -68,7 +68,7 @@ class SecureMessagingTest { fun testEncryptionCase2e() { val secureMessaging = SecureMessaging(paceKey) val commandApdu = CommandApdu.ofOptions(0x01, 0x02, 0x03, 0x04, 257) - val expectedEncryptedApdu = "0D02030400000E970201018E089F3EDDFBB1D3971D0000".decodeHex().toByteArray() + val expectedEncryptedApdu = Hex.decode("0D02030400000E970201018E089F3EDDFBB1D3971D0000") val encryptedCommandApdu = secureMessaging.encrypt(commandApdu) Assert.assertArrayEquals( expectedEncryptedApdu, @@ -83,7 +83,7 @@ class SecureMessagingTest { val secureMessaging = SecureMessaging(paceKey) val commandApdu = CommandApdu.ofOptions(0x01, 0x02, 0x03, 0x04, cmdData, null) val expectedEncryptedApdu = - "0D0203041D871101496C26D36306679609665A385C54DB378E08E7AAD918F260D8EF00".decodeHex().toByteArray() + Hex.decode("0D0203041D871101496C26D36306679609665A385C54DB378E08E7AAD918F260D8EF00") val encryptedCommandApdu = secureMessaging.encrypt(commandApdu) Assert.assertArrayEquals( expectedEncryptedApdu, @@ -97,7 +97,7 @@ class SecureMessagingTest { val cmdData = byteArrayOf(0x05, 0x06, 0x07, 0x08, 0x09, 0x0a) val commandApdu = CommandApdu.ofOptions(0x01, 0x02, 0x03, 0x04, cmdData, 127) val expectedEncryptedApdu = - "0D020304000020871101496C26D36306679609665A385C54DB3797017F8E0863D541F262BD445A0000".decodeHex().toByteArray() + Hex.decode("0D020304000020871101496C26D36306679609665A385C54DB3797017F8E0863D541F262BD445A0000") val encryptedCommandApdu = secureMessaging.encrypt(commandApdu) Assert.assertArrayEquals( expectedEncryptedApdu, @@ -112,13 +112,15 @@ class SecureMessagingTest { val commandApdu = CommandApdu.ofOptions(0x01, 0x02, 0x03, 0x04, cmdData, 127) val expectedEncryptedApdu = ( - "0D02030400012287820111013297D4AA774AB26AF8AD539C0A829BCA4D222D3EE2DB100CF86D7DB5A1FAC12B7623328DEFE3F6FDD41A993A" + - "C917BC17B364C3DD24740079DE60A3D0231A7185D36A77D37E147025913ADA00CD07736CFDE0DB2E0BB09B75C5773607E54A9D84181A" + - "CBC6F7726762A8BCE324C0B330548114154A13EDDBFF6DCBC3773DCA9A8494404BE4A5654273F9C2B9EBE1BD615CB39FFD0D3F2A0EEA" + - "29AA10B810D53EDB550FB741A68CC6B0BDF928F9EB6BC238416AACB4CF3002E865D486CF42D762C86EEBE6A2B25DECE2E88D569854A0" + - "7D3F146BC134BAF08B6EDCBEBDFF47EBA6AC7B441A1642B03253B588C49B69ABBEC92BA1723B7260DE8AD6158873141AFA7C70CFCF12" + - "5BA1DF77CA48025D049FCEE497017F8E0856332C83EABDF93C0000" - ).decodeHex().toByteArray() + Hex.decode( + "0D02030400012287820111013297D4AA774AB26AF8AD539C0A829BCA4D222D3EE2DB100CF86D7DB5A1FAC12B7623328DEFE3F6FDD41A993A" + + "C917BC17B364C3DD24740079DE60A3D0231A7185D36A77D37E147025913ADA00CD07736CFDE0DB2E0BB09B75C5773607E54A9D84181A" + + "CBC6F7726762A8BCE324C0B330548114154A13EDDBFF6DCBC3773DCA9A8494404BE4A5654273F9C2B9EBE1BD615CB39FFD0D3F2A0EEA" + + "29AA10B810D53EDB550FB741A68CC6B0BDF928F9EB6BC238416AACB4CF3002E865D486CF42D762C86EEBE6A2B25DECE2E88D569854A0" + + "7D3F146BC134BAF08B6EDCBEBDFF47EBA6AC7B441A1642B03253B588C49B69ABBEC92BA1723B7260DE8AD6158873141AFA7C70CFCF12" + + "5BA1DF77CA48025D049FCEE497017F8E0856332C83EABDF93C0000" + ) + ) val encryptedCommandApdu = secureMessaging.encrypt(commandApdu) Assert.assertArrayEquals( expectedEncryptedApdu, @@ -130,7 +132,7 @@ class SecureMessagingTest { @Test fun shouldDecryptDo99Apdu() { val secureMessaging = SecureMessaging(paceKey) - val apduToDecrypt = ResponseApdu("990290008E08087631D746F872729000".decodeHex().toByteArray()) + val apduToDecrypt = ResponseApdu(Hex.decode("990290008E08087631D746F872729000")) val decryptedApdu: ResponseApdu = secureMessaging.decrypt(apduToDecrypt) val expectedDecryptedApdu = ResponseApdu(byteArrayOf(0x90.toByte(), 0x00)) Assert.assertArrayEquals( @@ -144,9 +146,9 @@ class SecureMessagingTest { fun shouldDecryptDo87Apdu() { val secureMessaging = SecureMessaging(paceKey) val apduToDecrypt = - ResponseApdu("871101496c26d36306679609665a385c54db37990290008E08B7E9ED2A0C89FB3A9000".decodeHex().toByteArray()) + ResponseApdu(Hex.decode("871101496c26d36306679609665a385c54db37990290008E08B7E9ED2A0C89FB3A9000")) val decryptedApdu: ResponseApdu = secureMessaging.decrypt(apduToDecrypt) - val expectedDecryptedApdu = ResponseApdu("05060708090a9000".decodeHex().toByteArray()) + val expectedDecryptedApdu = ResponseApdu(Hex.decode("05060708090a9000")) Assert.assertArrayEquals( expectedDecryptedApdu.bytes, decryptedApdu.bytes @@ -157,7 +159,7 @@ class SecureMessagingTest { fun decryptShouldFailWithMissingStatusBytes() { val secureMessaging = SecureMessaging(paceKey) val apduToDecrypt = - ResponseApdu("871101496c26d36306679609665a385c54db378E08B7E9ED2A0C89FB3A9000".decodeHex().toByteArray()) + ResponseApdu(Hex.decode("871101496c26d36306679609665a385c54db378E08B7E9ED2A0C89FB3A9000")) try { secureMessaging.decrypt(apduToDecrypt) Assert.fail("Decrypting an APDU without DO99 should fail.") @@ -170,7 +172,7 @@ class SecureMessagingTest { fun decryptShouldFailWithMissingStatus() { val secureMessaging = SecureMessaging(paceKey) val apduToDecrypt = - ResponseApdu("871101496c26d36306679609665a385c54db37990290008E08B7E9ED2A0C89FB3A".decodeHex().toByteArray()) + ResponseApdu(Hex.decode("871101496c26d36306679609665a385c54db37990290008E08B7E9ED2A0C89FB3A")) try { secureMessaging.decrypt(apduToDecrypt) Assert.fail("Decrypting an APDU with missing status should fail.") @@ -183,7 +185,7 @@ class SecureMessagingTest { fun decryptShouldFailWithWrongCCS() { val secureMessaging = SecureMessaging(paceKey) val apduToDecrypt = - ResponseApdu("871101496c26d36306679609665a385c54db37990290008E08A7E9ED2A0C89FB3A9000".decodeHex().toByteArray()) + ResponseApdu(Hex.decode("871101496c26d36306679609665a385c54db37990290008E08A7E9ED2A0C89FB3A9000")) try { secureMessaging.decrypt(apduToDecrypt) Assert.fail("Decrypting an APDU without wrong DO8E should fail.") @@ -196,7 +198,7 @@ class SecureMessagingTest { fun decryptShouldFailWithMissingCCS() { val secureMessaging = SecureMessaging(paceKey) val apduToDecrypt = - ResponseApdu("871101496c26d36306679609665a385c54db37990290009000".decodeHex().toByteArray()) + ResponseApdu(Hex.decode("871101496c26d36306679609665a385c54db37990290009000")) try { secureMessaging.decrypt(apduToDecrypt) Assert.fail("Decrypting an APDU without DO8E should fail.") @@ -221,7 +223,7 @@ class SecureMessagingTest { @Throws(Exception::class) fun testDecryption() { val secureMessaging = SecureMessaging(paceKey) - val apduToDecrypt = ResponseApdu("990290008E08087631D746F872729000".decodeHex().toByteArray()) + val apduToDecrypt = ResponseApdu(Hex.decode("990290008E08087631D746F872729000")) val decryptedAPDU: ResponseApdu = secureMessaging.decrypt(apduToDecrypt) val expectedDecryptedAPDU = byteArrayOf(0x90.toByte(), 0x00) Assert.assertArrayEquals( diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/Version2Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/card/Version2Test.kt similarity index 65% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/Version2Test.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/card/Version2Test.kt index dfd29fb4..e4da81e9 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/Version2Test.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/card/Version2Test.kt @@ -16,41 +16,42 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.nfc.card -import okio.ByteString.Companion.decodeHex +import de.gematik.ti.erp.app.card.model.card.HealthCardVersion2 +import org.bouncycastle.util.encoders.Hex import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class Version2Test { @Test fun fromArray() { val version2: HealthCardVersion2 = - HealthCardVersion2.of("EF2BC003020000C103040302C21045474B47322020202020202020010304C403010000C503020000C703010000".decodeHex().toByteArray()) + HealthCardVersion2.of(Hex.decode("EF2BC003020000C103040302C21045474B47322020202020202020010304C403010000C503020000C703010000")) Assert.assertArrayEquals( - "020000".decodeHex().toByteArray(), + Hex.decode("020000"), version2.fillingInstructionsEfAtrVersion ) // C5 Assert.assertArrayEquals( - "".decodeHex().toByteArray(), + Hex.decode(""), version2.fillingInstructionsEfEnvironmentSettingsVersion ) // C3 Assert.assertArrayEquals( - "010000".decodeHex().toByteArray(), + Hex.decode("010000"), version2.fillingInstructionsEfGdoVersion ) // C4 Assert.assertArrayEquals( - "".decodeHex().toByteArray(), + Hex.decode(""), version2.fillingInstructionsEfKeyInfoVersion ) // C6 Assert.assertArrayEquals( - "010000".decodeHex().toByteArray(), + Hex.decode("010000"), version2.fillingInstructionsEfLoggingVersion ) // C7 - Assert.assertArrayEquals("020000".decodeHex().toByteArray(), version2.fillingInstructionsVersion) // C0 - Assert.assertArrayEquals("040302".decodeHex().toByteArray(), version2.objectSystemVersion) // C1 + Assert.assertArrayEquals(Hex.decode("020000"), version2.fillingInstructionsVersion) // C0 + Assert.assertArrayEquals(Hex.decode("040302"), version2.objectSystemVersion) // C1 Assert.assertArrayEquals( - "45474B47322020202020202020010304".decodeHex().toByteArray(), + Hex.decode("45474B47322020202020202020010304"), version2.productIdentificationObjectSystemVersion ) // C2 } diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/CommandApduTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/CommandApduTest.kt similarity index 97% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/CommandApduTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/CommandApduTest.kt index 6fc6481d..91fce160 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/CommandApduTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/CommandApduTest.kt @@ -16,10 +16,13 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.EXPECTED_LENGTH_WILDCARD_EXTENDED +import de.gematik.ti.erp.app.card.model.command.EXPECTED_LENGTH_WILDCARD_SHORT import org.junit.Assert -import org.junit.Test +import kotlin.test.Test import java.util.Arrays import java.util.Random diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GeneralAuthenticateCommandTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/GeneralAuthenticateCommandTest.kt similarity index 93% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GeneralAuthenticateCommandTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/GeneralAuthenticateCommandTest.kt index 0d1b4599..1afcf728 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/GeneralAuthenticateCommandTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/GeneralAuthenticateCommandTest.kt @@ -16,8 +16,10 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.generalAuthenticate import org.junit.Assert import org.junit.experimental.theories.DataPoint import org.junit.experimental.theories.Theories diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ManageSecurityEnvironmentCommandTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ManageSecurityEnvironmentCommandTest.kt similarity index 90% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ManageSecurityEnvironmentCommandTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ManageSecurityEnvironmentCommandTest.kt index c94e0d34..0dcb824f 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ManageSecurityEnvironmentCommandTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ManageSecurityEnvironmentCommandTest.kt @@ -16,8 +16,10 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.manageSecEnvWithoutCurves import org.junit.Assert import org.junit.experimental.theories.DataPoint import org.junit.experimental.theories.Theories diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ReadCommandTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ReadCommandTest.kt similarity index 92% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ReadCommandTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ReadCommandTest.kt index 64cc8f7d..583ffe79 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ReadCommandTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ReadCommandTest.kt @@ -16,11 +16,13 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ShortFileIdentifier +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.read +import de.gematik.ti.erp.app.card.model.identifier.ShortFileIdentifier import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class ReadCommandTest { private val testResource = TestResource() diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ResponseApduTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ResponseApduTest.kt similarity index 96% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ResponseApduTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ResponseApduTest.kt index 6bcf7c84..cafb7b40 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/ResponseApduTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/ResponseApduTest.kt @@ -16,10 +16,11 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import org.junit.Assert -import org.junit.Test +import kotlin.test.Test import java.util.Arrays import java.util.Random diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/SelectCommandTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/SelectCommandTest.kt similarity index 92% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/SelectCommandTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/SelectCommandTest.kt index adcdbd21..8f4e9318 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/SelectCommandTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/SelectCommandTest.kt @@ -16,12 +16,14 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ApplicationIdentifier -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.FileIdentifier +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.select +import de.gematik.ti.erp.app.card.model.identifier.ApplicationIdentifier +import de.gematik.ti.erp.app.card.model.identifier.FileIdentifier import org.junit.Assert -import org.junit.Test +import kotlin.test.Test import org.junit.experimental.theories.DataPoint import org.junit.experimental.theories.Theories import org.junit.experimental.theories.Theory diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/TestChannel.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/TestChannel.kt similarity index 77% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/TestChannel.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/TestChannel.kt index 7fd128e0..9c45b750 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/TestChannel.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/TestChannel.kt @@ -16,10 +16,13 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command -import de.gematik.ti.erp.app.cardwall.model.nfc.card.ICardChannel -import de.gematik.ti.erp.app.cardwall.model.nfc.card.NfcHealthCard +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.card.IHealthCard +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.HealthCardCommand +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import io.mockk.mockk import kotlinx.coroutines.runBlocking @@ -29,7 +32,7 @@ class TestChannel : ICardChannel { val lastCommandAPDUBytes: ByteArray get() = lastCommandAPDU?.bytes ?: ByteArray(0) - override val card: NfcHealthCard = mockk() + override val card: IHealthCard = mockk() override val maxTransceiveLength: Int = 261 diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/TestResource.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/TestResource.kt similarity index 83% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/TestResource.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/TestResource.kt index a5493d16..73504e9a 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/TestResource.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/command/TestResource.kt @@ -16,21 +16,17 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.nfc.command -import de.gematik.ti.erp.app.cardwall.model.nfc.card.CardKey -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ApplicationIdentifier -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.FileIdentifier -import de.gematik.ti.erp.app.cardwall.model.nfc.identifier.ShortFileIdentifier -import org.bouncycastle.jce.provider.BouncyCastleProvider +import de.gematik.ti.erp.app.card.model.card.CardKey +import de.gematik.ti.erp.app.card.model.identifier.ApplicationIdentifier +import de.gematik.ti.erp.app.card.model.identifier.FileIdentifier +import de.gematik.ti.erp.app.card.model.identifier.ShortFileIdentifier import org.yaml.snakeyaml.Yaml -import timber.log.Timber import java.io.File -import java.security.KeyFactory -import java.security.interfaces.ECPublicKey -import java.security.spec.X509EncodedKeySpec import java.util.Locale +@Suppress("ktlint:enum-entry-name-case") enum class ParameterEnum { PARAMETER_INT_OFFSET, PARAMETER_PIN, PARAMETER_INT_NE, PARAMETER_INT_RECORDNUMBER, PARAMETER_INT_FCPLENGTH, PARAMETER_INT_GETCHALLENGE_LENGTH, PARAMETER_INT_GETRANDOM, PARAMETER_INT_CHANNELNUMBER, PARAMETER_SID, PARAMETER_FILEIDENTIFIER, PARAMETER_APPLICATIONIDENTIFIER, PARAMETER_INT_IDDOMAIN, PARAMETER_GEMCVC, PARAMETER_ECPUBLICKEY, PARAMETER_FINGERPRINT, // PARAMETER_BYTEARRAY_MSE, PARAMETER_BYTEARRAY_DEFAULT, PARAMETER_RSAPUBLICKEY, PARAMETER_BYTEARRAY_INTERNLAUTH, PARAMETER_BYTEARRAY_REFERENCE, PARAMETER_BYTEARRAY_EXTERNALAUTH, PARAMETER_BYTEARRAY_CMDDATA, PARAMETER_BYTEARRAY_OID, PARAMETER_STRING_PACEINFOP256r1, PARAMETER_STRING_PACEINFOP384r1, PARAMETER_STRING_PACEINFOP512r1, PARAMETER_BYTEARRAY_CAN, PARAMETER_BYTEARRAY_NONZEZ, PARAMETER_BYTEARRAY_PK1, PARAMETER_BYTEARRAY_PK1PICC, PARAMETER_BYTEARRAY_PK2, PARAMETER_BYTEARRAY_PK2VP, PARAMETER_BYTEARRAY_PK2PICC, PARAMETER_BYTEARRAY_MACPCD, PARAMETER_BYTEARRAY_MACPICC, PARAMETER_STRING_ECCURVE_PK1 @@ -40,7 +36,7 @@ enum class ApduResultEnum { ACTIVATECOMMAND_APDU, ACTIVATERECORDCOMMAND_APDU, WRITECOMMAND_APDU, VERITYCOMMAND_APDU, TERMINATEDFCOMMAND_APDU, TERMINATECOMMAND_APDU, TERMINATECARDUSAGECOMMAND_APDU, SETLOGICALEOFCOMMAND_APDU, SEARCHRECORDCOMMAND_APDU, READRECORDCOMMAND_APDU, READCOMMAND_APDU, PSOVERIFYDIGITALSIGNATURECOMMAND_APDU, PSOVERIFYCERTIFICATECOMMAND_APDU, PSOTRANSCIPHER_APDU, PSOENCIPHER_APDU, PSODECIPHER_APDU, PSOCOMPUTEDIGITALSIGNATURECOMMAND_APDU, PSOCOMPUTECRYPTOGRAPHICCHECKSUM_APDU, PSOVERIFYCRYPTPGRAPHICCHECKSUMCOMMAND_APDU, MANAGESECURITYENVIRONMENTCOMMAND_APDU, MANAGECHANNELCOMMAND_APDU, LOADAPPLICATIONCOMMAND_APDU, LISTPUBLICKEYCOMMAND_APDU, INTERNALAUTHENTICATECOMMAND_APDU, GETRANDOMCOMMAND_APDU, GETPINSTATUSCOMMAND_APDU, GETCHALLENGECOMMAND_APDU, GENERATEASYMMETRICKEYPAIRCOMMAND_APDU, GENERALAUTHENTICATECOMMAND_APDU, FINGERPRINTCOMMAND_APDU, EXTERNALMUTUALAUTHENTICATECOMMAND_APDU, ERASERECORDCOMMAND_APDU, ERASECOMMAND_APDU, ENABLEVERIFICATIONREQUIREMENTCOMMAND_APDU, DISABLEVERIFICATIONREQUIREMENTCOMMAND_APDU, DELETERECORDCOMMAND_APDU, DELETECOMMAND_APDU, DEACTIVATERECORDCOMMAND_APDU, DEACTIVATECOMMAND_APDU, CHANGEREFERENCEDATACOMMAND_APDU, APPENDRECORDCOMMAND_APDU, SELECTCOMMAND_APDU, UPDATERECORDCOMMAND_APDU, UPDATECOMMAND_APDU } -private const val RESOURCE_PREFIX = "src/test/res/nfc" +private const val RESOURCE_PREFIX = "src/commonTest/resources/nfc" class TestResource { private val expectedApdusYml: Map> @@ -94,9 +90,9 @@ class TestResource { if (parameterEnum.name.startsWith("PARAMETER_INT")) { return ymlValue.toInt() } - when (parameterEnum) { - ParameterEnum.PARAMETER_FILEIDENTIFIER -> return FileIdentifier(ymlValue) - ParameterEnum.PARAMETER_APPLICATIONIDENTIFIER -> return ApplicationIdentifier(ymlValue) + return when (parameterEnum) { + ParameterEnum.PARAMETER_FILEIDENTIFIER -> FileIdentifier(ymlValue) + ParameterEnum.PARAMETER_APPLICATIONIDENTIFIER -> ApplicationIdentifier(ymlValue) ParameterEnum.PARAMETER_FINGERPRINT -> { val byteArray = ByteArray(128) var i = 0 @@ -104,11 +100,12 @@ class TestResource { byteArray[i] = i.toByte() i++ } - return byteArray + + byteArray } - ParameterEnum.PARAMETER_SID -> return ShortFileIdentifier(ymlValue) + ParameterEnum.PARAMETER_SID -> ShortFileIdentifier(ymlValue) + else -> ymlValue } - return ymlValue } companion object { @@ -171,16 +168,5 @@ class TestResource { } return hex } - - private fun loadECPublicKey(data: ByteArray?): ECPublicKey? { - try { - val keyFactory: KeyFactory = KeyFactory.getInstance("EC", BouncyCastleProvider()) - val publicKeySpec = X509EncodedKeySpec(data) - return keyFactory.generatePublic(publicKeySpec) as ECPublicKey - } catch (e: Exception) { - Timber.e("EC. data: $data", e) - } - return null - } } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/FileIdentifierTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/identifier/FileIdentifierTest.kt similarity index 90% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/FileIdentifierTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/identifier/FileIdentifierTest.kt index 91985fc1..593f2b05 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/FileIdentifierTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/identifier/FileIdentifierTest.kt @@ -16,10 +16,11 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.identifier +package de.gematik.ti.erp.app.nfc.identifier +import de.gematik.ti.erp.app.card.model.identifier.FileIdentifier import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class FileIdentifierTest { @Test diff --git a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ShortFileIdentifierTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/identifier/ShortFileIdentifierTest.kt similarity index 87% rename from android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ShortFileIdentifierTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/identifier/ShortFileIdentifierTest.kt index 7dce2ef8..5a68e95c 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/cardwall/model/nfc/identifier/ShortFileIdentifierTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/nfc/identifier/ShortFileIdentifierTest.kt @@ -16,10 +16,11 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.identifier +package de.gematik.ti.erp.app.nfc.identifier +import de.gematik.ti.erp.app.card.model.identifier.ShortFileIdentifier import org.junit.Assert -import org.junit.Test +import kotlin.test.Test class ShortFileIdentifierTest { @Test diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt new file mode 100644 index 00000000..11651f38 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepositoryTest.kt @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.profiles.repository + +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.ACTUAL_SCHEMA_VERSION +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.AuditEventEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.IdpAuthenticationDataEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PasswordEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ProfileEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.CommunicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.IngredientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.InsuranceInformationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationRequestEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MultiplePrescriptionInfoEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.OrganizationEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PatientEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.PractitionerEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.QuantityEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.RatioEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.ScannedTaskEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.SyncedTaskEntityV1 +import de.gematik.ti.erp.app.profiles.model.ProfilesData +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import java.time.Instant +import kotlin.test.Test +import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertFails + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfilesRepositoryTest : TestDB() { + @get:Rule + val coroutineRule = CoroutineTestRule() + private val defaultProfileName = "Sven Muster" + private val defaultInsurantName = "Sven Muster" + private val defaultInsuranceIdentifier = "123456789" + private val defaultInsuranceIdentifier1 = "987654321" + + private val defaultInsuranceName = "MusterKasse" + + private val defaultProfileName1 = "Gabi Muster" + + lateinit var realm: Realm + + lateinit var repo: ProfilesRepository + + @BeforeTest + fun setUp() { + realm = Realm.open( + RealmConfiguration.Builder( + schema = setOf( + ProfileEntityV1::class, + SyncedTaskEntityV1::class, + OrganizationEntityV1::class, + PractitionerEntityV1::class, + PatientEntityV1::class, + InsuranceInformationEntityV1::class, + MedicationRequestEntityV1::class, + MedicationDispenseEntityV1::class, + CommunicationEntityV1::class, + AddressEntityV1::class, + MedicationEntityV1::class, + IngredientEntityV1::class, + RatioEntityV1::class, + QuantityEntityV1::class, + ScannedTaskEntityV1::class, + IdpAuthenticationDataEntityV1::class, + AuditEventEntityV1::class, + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + ShippingContactEntityV1::class, + PharmacySearchEntityV1::class, + MultiplePrescriptionInfoEntityV1::class + ) + ) + .schemaVersion(ACTUAL_SCHEMA_VERSION) + .directory(tempDBPath) + .build() + ) + + repo = ProfilesRepository( + dispatchers = coroutineRule.dispatchers, + realm = realm + ) + } + + @Test + fun `profiles should return empty list `() = runTest { + repo.profiles().first().also { + assertEquals(0, it.size) + } + } + + @Test + fun `save profile - profiles should return activated profile`() = runTest { + repo.saveProfile(defaultProfileName1, true) + repo.profiles().first().also { + assertEquals(1, it.size) + assertEquals(defaultProfileName1, it[0].name) + assertEquals(true, it[0].active) + } + } + + @Test + fun `activate profile should activate profile and deactivate other profiles`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.saveProfile(defaultProfileName1, false) + repo.profiles().first().also { + assertEquals(2, it.size) + it.find { profile -> + profile.name == defaultProfileName1 + }.apply { + this?.let { defaultProfile2 -> + repo.activateProfile(defaultProfile2.id) + repo.profiles().first().also { profileList -> + profileList.find { profile -> + profile.name == defaultProfileName + }.apply { + this?.let { profile -> assertEquals(profile.active, false) } + } + profileList.find { profile -> + profile.name == defaultProfileName1 + }.apply { + this?.let { profile -> assertEquals(profile.active, true) } + } + } + } + } + } + } + + @Test + fun `remove active profile - should remove profile and activate an other profile`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.saveProfile(defaultProfileName1, false) + repo.profiles().first().also { profileList -> + assertEquals(2, profileList.size) + profileList.find { profile -> + profile.name == defaultProfileName + }.apply { + this?.let { + repo.removeProfile(it.id) + } + } + } + repo.profiles().first().also { newProfileList -> + assertEquals(1, newProfileList.size) + assertEquals(defaultProfileName1, newProfileList[0].name) + assertEquals(true, newProfileList[0].active) + } + } + + @Test + fun `remove last profile - should fail`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { profileList -> + assertEquals(1, profileList.size) + profileList.find { profile -> + profile.name == defaultProfileName + }.apply { + this?.let { + assertFails { + repo.removeProfile(it.id) + } + } + } + } + } + + @Test + fun `saveInsuranceInformation - should save InsuranceInformation to profile`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { profileList -> + profileList.find { profile -> + profile.name == defaultProfileName + }.apply { + this?.let { + repo.saveInsuranceInformation( + it.id, + defaultInsurantName, + defaultInsuranceIdentifier, + defaultInsuranceName + ) + } + } + } + repo.profiles().first().also { profileList -> + assertEquals(defaultInsurantName, profileList[0].insurantName) + assertEquals(defaultInsuranceIdentifier, profileList[0].insuranceIdentifier) + assertEquals(defaultInsuranceName, profileList[0].insuranceName) + } + } + + @Test + fun `saveInsuranceInformation on profile with other insuranceId - should fail`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { profileList -> + profileList.find { profile -> + profile.name == defaultProfileName + }.apply { + this?.let { + repo.saveInsuranceInformation( + it.id, + defaultInsurantName, + defaultInsuranceIdentifier, + defaultInsuranceName + ) + } + } + } + repo.profiles().first().also { profileList -> + assertFails { + repo.saveInsuranceInformation( + profileList[0].id, + defaultInsurantName, + defaultInsuranceIdentifier1, + defaultInsuranceName + ) + } + } + } + + @Test + fun `saveInsuranceInformation save the same insuranceId on 2 profiles - should fail`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.saveProfile(defaultProfileName1, true) + + repo.profiles().first().also { profileList -> + assertFails { + profileList.forEach { + repo.saveInsuranceInformation( + it.id, + defaultInsurantName, + defaultInsuranceIdentifier, + defaultInsuranceName + ) + } + } + } + } + + @Test + fun `update profile name with id`() = runTest { + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { + repo.updateProfileName(it[0].id, defaultProfileName1) + } + repo.profiles().first().also { + assertEquals(defaultProfileName1, it[0].name) + } + } + + @Test + fun `update profile color`() = runTest { + repo.saveProfile(defaultProfileName, true) + ProfilesData.ProfileColorNames.values().forEach { colorName -> + repo.profiles().first().also { + repo.updateProfileColor(it[0].id, colorName) + } + repo.profiles().first().also { + assertEquals(colorName, it[0].color) + } + } + } + + @Test + fun `update last authenticated`() = runTest { + val now = Instant.now() + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { + assertEquals(null, it[0].lastAuthenticated) + repo.updateLastAuthenticated(it[0].id, now) + } + repo.profiles().first().also { + assertEquals(now, it[0].lastAuthenticated) + } + } + + @Test + fun `save avatar figure`() = runTest { + repo.saveProfile(defaultProfileName, true) + ProfilesData.AvatarFigure.values().forEach { figure -> + repo.profiles().first().also { + repo.saveAvatarFigure(it[0].id, figure) + } + repo.profiles().first().also { + assertEquals(figure, it[0].avatarFigure) + } + } + } + + @Test + fun `save personalized profile image`() = runTest { + val profileImage = byteArrayOf(0x01.toByte(), 0x02.toByte()) + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { + assertEquals(null, it[0].personalizedImage) + repo.savePersonalizedProfileImage(it[0].id, profileImage) + } + repo.profiles().first().also { + it[0].personalizedImage?.let { bytes -> + assertEquals(0x01.toByte(), bytes[0]) + assertEquals(0x02.toByte(), bytes[1]) + } + } + } + + @Test + fun `clear personalized profile image`() = runTest { + val profileImage = byteArrayOf(0x01.toByte(), 0x02.toByte()) + repo.saveProfile(defaultProfileName, true) + repo.profiles().first().also { + repo.savePersonalizedProfileImage(it[0].id, profileImage) + } + repo.profiles().first().also { + repo.clearPersonalizedProfileImage(it[0].id) + } + repo.profiles().first().also { + assertEquals(null, it[0].personalizedImage) + } + } +} diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepositoryTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepositoryTest.kt new file mode 100644 index 00000000..09de500b --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepositoryTest.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.settings.repository + +import de.gematik.ti.erp.app.CoroutineTestRule +import de.gematik.ti.erp.app.db.TestDB +import de.gematik.ti.erp.app.db.ACTUAL_SCHEMA_VERSION +import de.gematik.ti.erp.app.db.entities.v1.AddressEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PasswordEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.PharmacySearchEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.ShippingContactEntityV1 +import de.gematik.ti.erp.app.db.queryFirst +import de.gematik.ti.erp.app.settings.model.SettingsData +import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import kotlin.test.Test +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.test.BeforeTest +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsRepositoryTest : TestDB() { + @get:Rule + val coroutineRule = CoroutineTestRule() + + lateinit var realm: Realm + + lateinit var repo: SettingsRepository + + @BeforeTest + fun setUp() { + realm = Realm.open( + RealmConfiguration.Builder( + schema = setOf( + SettingsEntityV1::class, + PharmacySearchEntityV1::class, + PasswordEntityV1::class, + ShippingContactEntityV1::class, + PharmacySearchEntityV1::class, + AddressEntityV1::class + ) + ) + .schemaVersion(ACTUAL_SCHEMA_VERSION) + .directory(tempDBPath) + .build() + ).also { + it.writeBlocking { + copyToRealm(SettingsEntityV1()) + } + } + + repo = SettingsRepository( + dispatchers = coroutineRule.dispatchers, + realm = realm + ) + } + + @Test + fun `general settings`() = runTest { + repo.general.first().also { + assertEquals(false, it.zoomEnabled) + assertEquals(false, it.userHasAcceptedInsecureDevice) + assertEquals( + LocalDateTime.of(2021, 10, 15, 0, 0).toInstant(ZoneOffset.UTC), + it.dataProtectionVersionAcceptedOn + ) + assertEquals(0, it.authenticationFails) + } + + repo.acceptInsecureDevice() + + repo.acceptUpdatedDataTerms(Instant.ofEpochSecond(123456)) + + repo.incrementNumberOfAuthenticationFailures() + repo.incrementNumberOfAuthenticationFailures() + + repo.saveZoomPreference(true) + + repo.general.first().also { + assertEquals(true, it.zoomEnabled) + assertEquals(true, it.userHasAcceptedInsecureDevice) + assertEquals(Instant.ofEpochSecond(123456), it.dataProtectionVersionAcceptedOn) + assertEquals(2, it.authenticationFails) + } + + repo.resetNumberOfAuthenticationFailures() + + repo.general.first().also { + assertEquals(true, it.zoomEnabled) + assertEquals(true, it.userHasAcceptedInsecureDevice) + assertEquals(Instant.ofEpochSecond(123456), it.dataProtectionVersionAcceptedOn) + assertEquals(0, it.authenticationFails) + } + } + + @Test + fun `pharmacy search`() = runTest { + repo.pharmacySearch.first().also { + assertEquals("", it.name) + assertEquals(false, it.locationEnabled) + assertEquals(false, it.ready) + assertEquals(false, it.deliveryService) + assertEquals(false, it.onlineService) + assertEquals(false, it.openNow) + } + + repo.savePharmacySearch( + SettingsData.PharmacySearch( + name = "Some Pharmacy", + locationEnabled = true, + ready = false, + deliveryService = true, + onlineService = false, + openNow = true + ) + ) + + repo.pharmacySearch.first().also { + assertEquals("Some Pharmacy", it.name) + assertEquals(true, it.locationEnabled) + assertEquals(false, it.ready) + assertEquals(true, it.deliveryService) + assertEquals(false, it.onlineService) + assertEquals(true, it.openNow) + } + } + + @Test + fun `authentication mode`() = runTest { + repo.authenticationMode.first().also { + assertTrue { + it is SettingsData.AuthenticationMode.Unspecified + } + } + + repo.saveAuthenticationMode( + SettingsData.AuthenticationMode.DeviceSecurity + ) + + repo.authenticationMode.first().also { + assertTrue { + it is SettingsData.AuthenticationMode.DeviceSecurity + } + } + + repo.saveAuthenticationMode( + SettingsData.AuthenticationMode.Password("Test123") + ) + + repo.authenticationMode.first().also { + assertTrue { + it is SettingsData.AuthenticationMode.Password + } + val password = it as SettingsData.AuthenticationMode.Password + + assertEquals(false, password.isValid("Test123456")) + assertEquals(true, password.isValid("Test123")) + } + } + + @Test + fun `authentication mode set to password - set other mode will reset stored credentials`() = runTest { + repo.saveAuthenticationMode( + SettingsData.AuthenticationMode.Password("Test123") + ) + + realm.queryFirst()!!.also { + assertEquals(true, it.hash.isNotEmpty()) + assertEquals(true, it.salt.isNotEmpty()) + } + + repo.saveAuthenticationMode( + SettingsData.AuthenticationMode.DeviceSecurity + ) + + realm.queryFirst()!!.also { + assertEquals(true, it.hash.isEmpty()) + assertEquals(true, it.salt.isEmpty()) + } + } +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/AdapterTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/AdapterTest.kt similarity index 62% rename from android/src/test/java/de/gematik/ti/erp/app/vau/AdapterTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/AdapterTest.kt index a3247205..ee407965 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/AdapterTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/AdapterTest.kt @@ -18,30 +18,27 @@ package de.gematik.ti.erp.app.vau -import com.squareup.moshi.Moshi -import de.gematik.ti.erp.app.vau.api.model.OCSPAdapter import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList -import de.gematik.ti.erp.app.vau.api.model.X509Adapter +import de.gematik.ti.erp.app.vau.api.model.X509Serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test class AdapterTest { @Test fun `base64 to x509 certificate`() { - X509Adapter().fromJson(TestCertificates.Vau.Base64).let { - assertEquals( - TestCertificates.Vau.SerialNumber.toBigInteger(), - it.serialNumber - ) - } + assertEquals( + TestCertificates.Vau.SerialNumber.toBigInteger(), + Json.decodeFromString(X509Serializer, "\"${TestCertificates.Vau.Base64}\"").serialNumber + ) } @Test fun `parse json cert list`() { - Moshi.Builder().add(X509Adapter()).build().adapter(UntrustedCertList::class.java) - .fromJson(TestCertificates.Vau.JsonCertList)!!.let { + Json.decodeFromString(TestCertificates.Vau.JsonCertList).let { assertEquals(0, it.addRoots.size) assertEquals(1, it.caCerts.size) assertEquals(3, it.eeCerts.size) @@ -50,9 +47,6 @@ class AdapterTest { @Test fun `parse ocsp response list`() { - Moshi.Builder().add(OCSPAdapter()).build().adapter(UntrustedOCSPList::class.java) - .fromJson(TestCertificates.OCSPList.JsonOCSPList)!!.let { - assertEquals(3, it.responses.size) - } + assertEquals(3, Json.decodeFromString(TestCertificates.OCSP.JsonOCSPList).responses.size) } } diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/CertUtilsTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/CertUtilsTest.kt similarity index 93% rename from android/src/test/java/de/gematik/ti/erp/app/vau/CertUtilsTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/CertUtilsTest.kt index a0a69e51..65d1a6e9 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/CertUtilsTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/CertUtilsTest.kt @@ -18,13 +18,13 @@ package de.gematik.ti.erp.app.vau -import org.apache.commons.codec.binary.Base64 import org.bouncycastle.cert.ocsp.BasicOCSPResp import org.bouncycastle.cert.ocsp.OCSPResp +import org.bouncycastle.util.encoders.Base64 import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test class CertUtilsTest { @Test @@ -100,7 +100,7 @@ class CertUtilsTest { ) ) val ocspResp = - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP3.Base64)).responseObject as BasicOCSPResp + OCSPResp(Base64.decode(TestCertificates.OCSP3.Base64)).responseObject as BasicOCSPResp assertArrayEquals( certChain.toTypedArray(), @@ -135,9 +135,9 @@ class CertUtilsTest { @Test fun `filter chains by oid and multiple ocsp responses - return one chain`() { val ocspRespVau = - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP3.Base64)).responseObject as BasicOCSPResp + OCSPResp(Base64.decode(TestCertificates.OCSP3.Base64)).responseObject as BasicOCSPResp val ocspRespIdp = - OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp val certChain = listOf( listOf( diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/ClientCryptoTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/ClientCryptoTest.kt similarity index 97% rename from android/src/test/java/de/gematik/ti/erp/app/vau/ClientCryptoTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/ClientCryptoTest.kt index cfcb9236..0b6ef36d 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/ClientCryptoTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/ClientCryptoTest.kt @@ -31,22 +31,23 @@ import okio.ByteString.Companion.decodeHex import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Test import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.test.Test +import kotlin.test.assertContentEquals class ClientCryptoTest { // util tests @Test fun `byte array encoded to lower case hex`() { - assertArrayEquals( + assertContentEquals( "Hello test".toByteArray(), "Hello test".toByteArray().toLowerCaseHex().decodeToString().decodeHex().toByteArray() ) - assertArrayEquals( + assertContentEquals( byteArrayOf(-20, 10, 120, 0, -127), byteArrayOf(-20, 10, 120, 0, -127).toLowerCaseHex().decodeToString().decodeHex().toByteArray() ) @@ -247,7 +248,8 @@ class ClientCryptoTest { """.trimIndent() val vauRawResp = AesGcm.encrypt( - SecretKeySpec(symKey, "AES"), VauAesGcmSpec.V1, + SecretKeySpec(symKey, "AES"), + VauAesGcmSpec.V1, responseInnerHttp.toByteArray(), cryptoConfig = TestCryptoConfig ) @@ -324,7 +326,8 @@ class ClientCryptoTest { "Some Content" val encryptedResponseFromServer = AesGcm.encrypt( - SecretKeySpec(symKey, "AES"), VauAesGcmSpec.V1, + SecretKeySpec(symKey, "AES"), + VauAesGcmSpec.V1, responseBody.toByteArray(), TestCryptoConfig ) diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/CryptoTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/CryptoTest.kt similarity index 99% rename from android/src/test/java/de/gematik/ti/erp/app/vau/CryptoTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/CryptoTest.kt index 317f85aa..4687d158 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/CryptoTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/CryptoTest.kt @@ -22,7 +22,7 @@ import okio.ByteString.Companion.decodeHex import org.bouncycastle.jce.ECNamedCurveTable import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test import java.security.KeyFactory import java.security.KeyPair import java.security.KeyPairGenerator diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/OCSPUtilsTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/OCSPUtilsTest.kt similarity index 75% rename from android/src/test/java/de/gematik/ti/erp/app/vau/OCSPUtilsTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/OCSPUtilsTest.kt index affceaa3..dc2507da 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/OCSPUtilsTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/OCSPUtilsTest.kt @@ -18,30 +18,30 @@ package de.gematik.ti.erp.app.vau -import org.apache.commons.codec.binary.Base64 import org.bouncycastle.cert.ocsp.BasicOCSPResp import org.bouncycastle.cert.ocsp.OCSPResp +import org.bouncycastle.util.encoders.Base64 import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test import java.time.Duration class OCSPUtilsTest { @Test fun `valid ocsp response cert is validated against its ca cert`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp ocspResp.checkSignatureWith(TestCertificates.OCSP1.SignerCert.X509Certificate) } @Test(expected = Exception::class) fun `valid ocsp response cert is validated against wrong ca cert - should throw exception`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp ocspResp.checkSignatureWith(TestCertificates.CA10.X509Certificate) } @Test fun `valid ocsp response cert is valid within 12 hours`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp ocspResp.checkValidity(Duration.ofHours(12), TestCertificates.OCSP1.ProducedAt.plus(Duration.ofHours(0))) ocspResp.checkValidity(Duration.ofHours(12), TestCertificates.OCSP1.ProducedAt.plus(Duration.ofHours(5))) @@ -50,28 +50,28 @@ class OCSPUtilsTest { @Test(expected = Exception::class) fun `valid ocsp response cert is invalid over 12 hours - throws exception`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp ocspResp.checkValidity(Duration.ofHours(12), TestCertificates.OCSP1.ProducedAt.plus(Duration.ofHours(13))) } @Test(expected = Exception::class) fun `valid ocsp response cert is invalid if current time is in the past - throws exception`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp ocspResp.checkValidity(Duration.ofHours(12), TestCertificates.OCSP1.ProducedAt.minus(Duration.ofHours(1))) } @Test fun `valid single response matches with its issuer certificate - returns true`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp assertTrue(ocspResp.responses.first().matchesIssuer(TestCertificates.CA10.X509Certificate)) } @Test fun `valid single response doesn't match with wrong issuer certificate - returns false`() { - val ocspResp = OCSPResp(Base64.decodeBase64(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp + val ocspResp = OCSPResp(Base64.decode(TestCertificates.OCSP1.Base64)).responseObject as BasicOCSPResp assertFalse(ocspResp.responses.first().matchesIssuer(TestCertificates.CA11.X509Certificate)) } diff --git a/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/TestData.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/TestData.kt new file mode 100644 index 00000000..a5bf027d --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/TestData.kt @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.vau + +import de.gematik.ti.erp.app.vau.api.model.UntrustedCertList +import de.gematik.ti.erp.app.vau.api.model.UntrustedOCSPList +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okio.ByteString.Companion.decodeBase64 +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.SecureRandom +import java.time.Instant + +val BCProvider = BouncyCastleProvider() + +val TestCryptoConfig = object : VauCryptoConfig { + override val provider = BCProvider + override val random = SecureRandom() +} + +fun x509PEMCertificateAsBase64(data: String) = + data.removePrefix("-----BEGIN CERTIFICATE-----") + .removeSuffix("-----END CERTIFICATE-----").replace("\n", "").trim() + +fun x509Certificate(data: String) = + X509CertificateHolder(x509PEMCertificateAsBase64(data).decodeBase64()!!.toByteArray()) + +fun base64X509Certificate(certInBase64: String) = + X509CertificateHolder(certInBase64.decodeBase64()!!.toByteArray()) + +object TestCertificates { + + object Vau { + val OID = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 2) // oid = 1.2.276.0.76.4.258 + + const val Base64 = + "MIIC7jCCApWgAwIBAgIHATwrYu8gtzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDEwMDcwMDAwMDBaFw0yNTA4MDcwMDAwMDBaMF4xCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDEnMCUGA1UEAwweRVJQIFJlZmVyZW56ZW50d2lja2x1bmcgRkQgRW5jMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABKYLzjl704qFX+oEuUOyLV70i2Bn2K4jekh/YOxExtdADB3X/q7fX/tVr09GtDRxe3h1yov9TwuHaHYh91RlyMejggEUMIIBEDAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgSMwCgYIKoIUAEwEgUowHQYDVR0OBBYEFK5+wVL9g8tGve6b1MdHK1xs62H7MDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAOBgNVHQ8BAf8EBAMCAwgwUwYFKyQIAwMESjBIMEYwRDBCMEAwMgwwRS1SZXplcHQgdmVydHJhdWVuc3fDvHJkaWdlIEF1c2bDvGhydW5nc3VtZ2VidW5nMAoGCCqCFABMBIICMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMAoGCCqGSM49BAMCA0cAMEQCIGZ20lLY2WEAGOTmNEFBB1EeU645fE0Iy2U9ypFHMlw4AiAVEP0HYut0Z8sKUk6WVanMmKXjfxO/qgQFzjsbq954dw==" + val X509Certificate by lazy { base64X509Certificate(Base64) } + + const val SerialNumber = "347632017809591" + + // FIXME second ca is ocsp response only; production ocsp uses same ca as vau/idp + val JsonCertList = """ + { + "add_roots": [], + "ca_certs": [ + "MIIDGjCCAr+gAwIBAgIBFzAKBggqhkjOPQQDAjCBgTELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxNDAyBgNVBAsMK1plbnRyYWxlIFJvb3QtQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxGzAZBgNVBAMMEkdFTS5SQ0EzIFRFU1QtT05MWTAeFw0xNzA4MzAxMTM2MjJaFw0yNTA4MjgxMTM2MjFaMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABDFinQgzfsT1CN0QWwdm7e2JiaDYHocCiy1TWpOPyHwoPC54RULeUIBJeX199Qm1FFpgeIRP1E8cjbHGNsRbju6jggEgMIIBHDAdBgNVHQ4EFgQUKPD45qnId8xDRduartc6g6wOD6gwHwYDVR0jBBgwFoAUB5AzLXVTXn/4yDe/fskmV2jfONIwQgYIKwYBBQUHAQEENjA0MDIGCCsGAQUFBzABhiZodHRwOi8vb2NzcC5yb290LWNhLnRpLWRpZW5zdGUuZGUvb2NzcDASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMFsGA1UdEQRUMFKgUAYDVQQKoEkMR2dlbWF0aWsgR2VzZWxsc2NoYWZ0IGbDvHIgVGVsZW1hdGlrYW53ZW5kdW5nZW4gZGVyIEdlc3VuZGhlaXRza2FydGUgbWJIMAoGCCqGSM49BAMCA0kAMEYCIQCprLtIIRx1Y4mKHlNngOVAf6D7rkYSa723oRyX7J2qwgIhAKPi9GSJyYp4gMTFeZkqvj8pcAqxNR9UKV7UYBlHrdxC" + ], + "ee_certs": [ + "MIIC7jCCApWgAwIBAgIHATwrYu8gtzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDEwMDcwMDAwMDBaFw0yNTA4MDcwMDAwMDBaMF4xCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDEnMCUGA1UEAwweRVJQIFJlZmVyZW56ZW50d2lja2x1bmcgRkQgRW5jMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABKYLzjl704qFX+oEuUOyLV70i2Bn2K4jekh/YOxExtdADB3X/q7fX/tVr09GtDRxe3h1yov9TwuHaHYh91RlyMejggEUMIIBEDAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgSMwCgYIKoIUAEwEgUowHQYDVR0OBBYEFK5+wVL9g8tGve6b1MdHK1xs62H7MDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAOBgNVHQ8BAf8EBAMCAwgwUwYFKyQIAwMESjBIMEYwRDBCMEAwMgwwRS1SZXplcHQgdmVydHJhdWVuc3fDvHJkaWdlIEF1c2bDvGhydW5nc3VtZ2VidW5nMAoGCCqCFABMBIICMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMAoGCCqGSM49BAMCA0cAMEQCIGZ20lLY2WEAGOTmNEFBB1EeU645fE0Iy2U9ypFHMlw4AiAVEP0HYut0Z8sKUk6WVanMmKXjfxO/qgQFzjsbq954dw==", + "MIICsTCCAligAwIBAgIHA61I5ACUjTAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA4MDQwMDAwMDBaFw0yNTA4MDQyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAxMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABJZQrG1NWxIB3kz/6Z2zojlkJqN3vJXZ3EZnJ6JXTXw5ZDFZ5XjwWmtgfomv3VOV7qzI5ycUSJysMWDEu3mqRcajge0wgeowHQYDVR0OBBYEFJ8DVLAZWT+BlojTD4MT/Na+ES8YMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgUswCgYIKoIUAEwEgSMwHwYDVR0jBBgwFoAUKPD45qnId8xDRduartc6g6wOD6gwLQYFKyQIAwMEJDAiMCAwHjAcMBowDAwKSURQLURpZW5zdDAKBggqghQATASCBDAOBgNVHQ8BAf8EBAMCB4AwCgYIKoZIzj0EAwIDRwAwRAIgVBPhAwyX8HAVH0O0b3+VazpBAWkQNjkEVRkv+EYX1e8CIFdn4O+nivM+XVi9xiKK4dW1R7MD334OpOPTFjeEhIVV", + "MIICsTCCAligAwIBAgIHAbssqQhqOzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTUwMDAwMDBaFw0yNjAxMTUyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAzMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABIYZnwiGAn5QYOx43Z8MwaZLD3r/bz6BTcQO5pbeum6qQzYD5dDCcriw/VNPPZCQzXQPg4StWyy5OOq9TogBEmOjge0wgeowDgYDVR0PAQH/BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIUAEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYBBQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFC94M9LgW44lNgoAbkPaomnLjS8/MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAwRAIgCg4yZDWmyBirgxzawz/S8DJnRFKtYU/YGNlRc7+kBHcCIBuzba3GspqSmoP1VwMeNNKNaLsgV8vMbDJb30aqaiX1" + ] + } + """.trimIndent() + + val CertList: UntrustedCertList by lazy { Json.decodeFromString(JsonCertList) } + + val ValidTimestamp: Instant = Instant.ofEpochSecond(1615368104) // 2021-03-10T09:21:44.000Z + val ExpiredTimestamp: Instant = + Instant.ofEpochSecond(1899364896) // 2030-03-10T09:21:36.812Z + } + + object Idp1 { + val OID = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 4) // oid = 1.2.276.0.76.4.260 + + const val Base64 = + "MIICsTCCAligAwIBAgIHA61I5ACUjTAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA4MDQwMDAwMDBaFw0yNTA4MDQyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAxMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABJZQrG1NWxIB3kz/6Z2zojlkJqN3vJXZ3EZnJ6JXTXw5ZDFZ5XjwWmtgfomv3VOV7qzI5ycUSJysMWDEu3mqRcajge0wgeowHQYDVR0OBBYEFJ8DVLAZWT+BlojTD4MT/Na+ES8YMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgUswCgYIKoIUAEwEgSMwHwYDVR0jBBgwFoAUKPD45qnId8xDRduartc6g6wOD6gwLQYFKyQIAwMEJDAiMCAwHjAcMBowDAwKSURQLURpZW5zdDAKBggqghQATASCBDAOBgNVHQ8BAf8EBAMCB4AwCgYIKoZIzj0EAwIDRwAwRAIgVBPhAwyX8HAVH0O0b3+VazpBAWkQNjkEVRkv+EYX1e8CIFdn4O+nivM+XVi9xiKK4dW1R7MD334OpOPTFjeEhIVV" + val X509Certificate by lazy { base64X509Certificate(Base64) } + + const val SerialNumber = "1034953504625805" + } + + object Idp2 { + val OID = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 4) // oid = 1.2.276.0.76.4.260 + + const val Base64 = + "MIICsTCCAligAwIBAgIHAbssqQhqOzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTUwMDAwMDBaFw0yNjAxMTUyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAzMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABIYZnwiGAn5QYOx43Z8MwaZLD3r/bz6BTcQO5pbeum6qQzYD5dDCcriw/VNPPZCQzXQPg4StWyy5OOq9TogBEmOjge0wgeowDgYDVR0PAQH/BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIUAEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYBBQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFC94M9LgW44lNgoAbkPaomnLjS8/MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAwRAIgCg4yZDWmyBirgxzawz/S8DJnRFKtYU/YGNlRc7+kBHcCIBuzba3GspqSmoP1VwMeNNKNaLsgV8vMbDJb30aqaiX1" + val X509Certificate by lazy { base64X509Certificate(Base64) } + + const val SerialNumber = "487275465566779" + } + + object Idp3 { + val OID = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 4) // oid = 1.2.276.0.76.4.260 + + private val data = """ + -----BEGIN CERTIFICATE----- + MIICsTCCAligAwIBAgIHA8OQFtdAtTAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMC + REUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtv + bXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQD + DBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTMwMDAwMDBaFw0yNjAx + MTMyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1Qt + T05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAyMFowFAYHKoZIzj0C + AQYJKyQDAwIIAQEHA0IABEC6Sfy6RcfusiYbG+Drx8FNZIS574ojsGDr5n+XJSu8 + mHuknfNkoMmSbytt4br0YGihOixcmBKy80UfSLdXGe6jge0wgeowDgYDVR0PAQH/ + BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIU + AEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSME + GDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYB + BQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFLM7 + Gd6tlX+bjswtS+tVxkbTwxC0MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAw + RAIgfKKll8KtEPLdaUWwF7ftbEvkIdz9KXhL4cKRyozGQjECIDxby8TX2iWfwVhf + HoxmpTf+D3eCRHhmnwJWcIgm1tF0 + -----END CERTIFICATE----- + """.trimIndent() + val Base64 by lazy { x509PEMCertificateAsBase64(data) } + val X509Certificate by lazy { x509Certificate(data) } + + const val SerialNumber = "1059448556044469" + } + + object Idp4 { + val OID = byteArrayOf(6, 8, 42, -126, 20, 0, 76, 4, -126, 4) // oid = 1.2.276.0.76.4.260 + + private val data = """ + -----BEGIN CERTIFICATE----- + MIICsTCCAligAwIBAgIHAbssqQhqOzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMC + REUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtv + bXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQD + DBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTUwMDAwMDBaFw0yNjAx + MTUyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1Qt + T05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAzMFowFAYHKoZIzj0C + AQYJKyQDAwIIAQEHA0IABIYZnwiGAn5QYOx43Z8MwaZLD3r/bz6BTcQO5pbeum6q + QzYD5dDCcriw/VNPPZCQzXQPg4StWyy5OOq9TogBEmOjge0wgeowDgYDVR0PAQH/ + BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIU + AEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSME + GDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYB + BQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFC94 + M9LgW44lNgoAbkPaomnLjS8/MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAw + RAIgCg4yZDWmyBirgxzawz/S8DJnRFKtYU/YGNlRc7+kBHcCIBuzba3GspqSmoP1 + VwMeNNKNaLsgV8vMbDJb30aqaiX1 + -----END CERTIFICATE----- + """.trimIndent() + val Base64 by lazy { x509PEMCertificateAsBase64(data) } + val X509Certificate by lazy { x509Certificate(data) } + } + + /** + * First response of [OCSP]. + */ + object OCSP1 { + const val Base64 = + "MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAxWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcDrUjkAJSNgAAYDzIwMjEwNTE3MDYyMzAxWqARGA8yMDIxMDUxNzA2MjMwMVqhIzAhMB8GCSsGAQUFBzABAgQSBBDkdyImUBsO+Q8iAA2xbXu8MAkGByqGSM49BAEDRwAwRAIgW+JlwUmnZCVsME2kOyQlcqF01Lel/0nQdE6IaZmFADECIGhOH1k5Dzq42y2jCxZCzxevRc6vY1o8ky0Xy4DxLIWJoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + val ProducedAt = Instant.ofEpochSecond(1621232581) // 2021-05-17T08:23:01.000+0200 + val CertToCheckSerialNumber = "1034953504625805" // IDP 1 + + object SignerCert { + val Base64 = "MIICmjCCAkCgAwIBAgIHA602RERCazAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA1MDYwMDAwMDBaFw0yMzA1MDYyMzU5NTlaMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkwWjAUBgcqhkjOPQIBBgkrJAMDAggBAQcDQgAEGwfkaELN0cr5DfqP1bNsWZS2XiuH6reLPZLHBSLkyFp/SzTKvNDdm7nKlp6Norg1z1njhyapRraaCzRS6VreD6OBxzCBxDAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDAOBgNVHQ8BAf8EBAMCBkAwFQYDVR0gBA4wDDAKBggqghQATASBIzATBgNVHSUEDDAKBggrBgEFBQcDCTAMBgNVHRMBAf8EAjAAMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAdBgNVHQ4EFgQUHKqJlrsbnD6bAIyF2WScrHtUmeIwCgYIKoZIzj0EAwIDSAAwRQIhAIkd0/4EtDLRRnb0B8mgmvlxepYrLKX/lkVGoXy0D64OAiAkOCmXOwGJExZxxRm4diJ/GPzZI4ecAnaVqnikYAQVCQ==" + val X509Certificate by lazy { base64X509Certificate(Base64) } + } + } + + /** + * Second response of [OCSP]. + */ + object OCSP2 { + const val Base64 = + "MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAxWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcBuyypCGo7gAAYDzIwMjEwNTE3MDYyMzAxWqARGA8yMDIxMDUxNzA2MjMwMVqhIzAhMB8GCSsGAQUFBzABAgQSBBDIsivTG9WljP4InmqVdKQmMAkGByqGSM49BAEDRwAwRAIgZMCyRhqMOaEG10KPz3mL5Yh7oX9fiIdBl8WrxLT2SewCIEvjzedVlnbt/j4e7VALo2xl8wvOcYe8gT04+PqH5vkfoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + val ProducedAt = Instant.ofEpochSecond(1621232581) // 2021-05-17T08:23:01.000+0200 + val CertToCheckSerialNumber = "487275465566779" // IDP 2 + } + + /** + * Third response of [OCSP]. + */ + object OCSP3 { + const val Base64 = + "MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAwWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcBPCti7yC3gAAYDzIwMjEwNTE3MDYyMzAwWqARGA8yMDIxMDUxNzA2MjMwMFqhIzAhMB8GCSsGAQUFBzABAgQSBBAWpjYsPzj/U96/S1MvypTWMAkGByqGSM49BAEDRwAwRAIgXfEC3h/1H2/aHGEyJY9L59S6NbqdkStBBk2vczj+3mwCIASMGDqPuhA7ZLBJ5HhHpwKYEQw/YPluyBMnz7j2dXtPoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + val ProducedAt = Instant.ofEpochSecond(1621232580) // 2021-05-17T08:23:00.000+0200 + val CertToCheckSerialNumber = "347632017809591" // VAU + } + + /** + * +- OCSP Response --------+ + * | | + * | +-------------+ | verify with +--------------+ verify with +--------------+ + * | | Certificate |--------|---------------| OCSP EE Cert |---------------| OCSP CA Cert | + * | +-------------+ | +--------------+ +--------------+ + * | | + * | +- Single Response -+ | equals +--------------------+ + * | | Certificate ID |--|----------| VAU/IDP CA Cert ID | + * | +-------------------+ | +--------------------+ + * | | + * +------------------------+ + */ + object OCSP { + @Suppress("MaxLineLength") + val JsonOCSPList = """ + { + "OCSP Responses": [ + "MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAxWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcDrUjkAJSNgAAYDzIwMjEwNTE3MDYyMzAxWqARGA8yMDIxMDUxNzA2MjMwMVqhIzAhMB8GCSsGAQUFBzABAgQSBBDkdyImUBsO+Q8iAA2xbXu8MAkGByqGSM49BAEDRwAwRAIgW+JlwUmnZCVsME2kOyQlcqF01Lel/0nQdE6IaZmFADECIGhOH1k5Dzq42y2jCxZCzxevRc6vY1o8ky0Xy4DxLIWJoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ", + "MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAxWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcBuyypCGo7gAAYDzIwMjEwNTE3MDYyMzAxWqARGA8yMDIxMDUxNzA2MjMwMVqhIzAhMB8GCSsGAQUFBzABAgQSBBDIsivTG9WljP4InmqVdKQmMAkGByqGSM49BAEDRwAwRAIgZMCyRhqMOaEG10KPz3mL5Yh7oX9fiIdBl8WrxLT2SewCIEvjzedVlnbt/j4e7VALo2xl8wvOcYe8gT04+PqH5vkfoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ", + "MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAwWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcBPCti7yC3gAAYDzIwMjEwNTE3MDYyMzAwWqARGA8yMDIxMDUxNzA2MjMwMFqhIzAhMB8GCSsGAQUFBzABAgQSBBAWpjYsPzj/U96/S1MvypTWMAkGByqGSM49BAEDRwAwRAIgXfEC3h/1H2/aHGEyJY9L59S6NbqdkStBBk2vczj+3mwCIASMGDqPuhA7ZLBJ5HhHpwKYEQw/YPluyBMnz7j2dXtPoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + ] + } + """.trimIndent() + + val OCSPList: UntrustedOCSPList by lazy { Json.decodeFromString(JsonOCSPList) } + } + + object CA10 { + private val data = """ + -----BEGIN CERTIFICATE----- + MIIDGjCCAr+gAwIBAgIBFzAKBggqhkjOPQQDAjCBgTELMAkGA1UEBhMCREUxHzAd + BgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxNDAyBgNVBAsMK1plbnRyYWxl + IFJvb3QtQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxGzAZBgNVBAMMEkdF + TS5SQ0EzIFRFU1QtT05MWTAeFw0xNzA4MzAxMTM2MjJaFw0yNTA4MjgxMTM2MjFa + MIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJ + RDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3Ry + dWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMFowFAYHKoZI + zj0CAQYJKyQDAwIIAQEHA0IABDFinQgzfsT1CN0QWwdm7e2JiaDYHocCiy1TWpOP + yHwoPC54RULeUIBJeX199Qm1FFpgeIRP1E8cjbHGNsRbju6jggEgMIIBHDAdBgNV + HQ4EFgQUKPD45qnId8xDRduartc6g6wOD6gwHwYDVR0jBBgwFoAUB5AzLXVTXn/4 + yDe/fskmV2jfONIwQgYIKwYBBQUHAQEENjA0MDIGCCsGAQUFBzABhiZodHRwOi8v + b2NzcC5yb290LWNhLnRpLWRpZW5zdGUuZGUvb2NzcDASBgNVHRMBAf8ECDAGAQH/ + AgEAMA4GA1UdDwEB/wQEAwIBBjAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMFsGA1Ud + EQRUMFKgUAYDVQQKoEkMR2dlbWF0aWsgR2VzZWxsc2NoYWZ0IGbDvHIgVGVsZW1h + dGlrYW53ZW5kdW5nZW4gZGVyIEdlc3VuZGhlaXRza2FydGUgbWJIMAoGCCqGSM49 + BAMCA0kAMEYCIQCprLtIIRx1Y4mKHlNngOVAf6D7rkYSa723oRyX7J2qwgIhAKPi + 9GSJyYp4gMTFeZkqvj8pcAqxNR9UKV7UYBlHrdxC + -----END CERTIFICATE----- + """.trimIndent() + val Base64 by lazy { x509PEMCertificateAsBase64(data) } + val X509Certificate by lazy { x509Certificate(data) } + } + + object CA11 { + private val data = """ + -----BEGIN CERTIFICATE----- + MIIDGDCCAr+gAwIBAgIBFjAKBggqhkjOPQQDAjCBgTELMAkGA1UEBhMCREUxHzAd + BgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxNDAyBgNVBAsMK1plbnRyYWxl + IFJvb3QtQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxGzAZBgNVBAMMEkdF + TS5SQ0EzIFRFU1QtT05MWTAeFw0xNzA4MzAxMTM2MDhaFw0yNTA4MjgxMTM2MDda + MIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJ + RDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3Ry + dWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTEgVEVTVC1PTkxZMFowFAYHKoZI + zj0CAQYJKyQDAwIIAQEHA0IABGTK1hKec85JygijmQ2crZYDrpMqbxX73b9BUDQ/ + b/zHoa1Liq6icJKrlCFTjJ1J7EAaAxLsGG0N/XjxWSxlBIGjggEgMIIBHDAdBgNV + HQ4EFgQUTBRlQvR625Hnyqqo6Q4ezy2L57owHwYDVR0jBBgwFoAUB5AzLXVTXn/4 + yDe/fskmV2jfONIwQgYIKwYBBQUHAQEENjA0MDIGCCsGAQUFBzABhiZodHRwOi8v + b2NzcC5yb290LWNhLnRpLWRpZW5zdGUuZGUvb2NzcDASBgNVHRMBAf8ECDAGAQH/ + AgEAMA4GA1UdDwEB/wQEAwIBBjAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMFsGA1Ud + EQRUMFKgUAYDVQQKoEkMR2dlbWF0aWsgR2VzZWxsc2NoYWZ0IGbDvHIgVGVsZW1h + dGlrYW53ZW5kdW5nZW4gZGVyIEdlc3VuZGhlaXRza2FydGUgbWJIMAoGCCqGSM49 + BAMCA0cAMEQCIHNbTALWZyWkNTfmVHlADw7lmjF/mPgk4cT0iIavuddAAiBqcZFt + l2T02k5YDqltLug2EYy+naFfl3gEI+qCS7fsAg== + -----END CERTIFICATE----- + """.trimIndent() + val Base64 by lazy { x509PEMCertificateAsBase64(data) } + val X509Certificate by lazy { x509Certificate(data) } + } + + object RCA3 { + private val data = """ + -----BEGIN CERTIFICATE----- + MIICkzCCAjmgAwIBAgIBATAKBggqhkjOPQQDAjCBgTELMAkGA1UEBhMCREUxHzAd + BgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxNDAyBgNVBAsMK1plbnRyYWxl + IFJvb3QtQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxGzAZBgNVBAMMEkdF + TS5SQ0EzIFRFU1QtT05MWTAeFw0xNzA4MTEwODM4NDVaFw0yNzA4MDkwODM4NDVa + MIGBMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJ + RDE0MDIGA1UECwwrWmVudHJhbGUgUm9vdC1DQSBkZXIgVGVsZW1hdGlraW5mcmFz + dHJ1a3R1cjEbMBkGA1UEAwwSR0VNLlJDQTMgVEVTVC1PTkxZMFowFAYHKoZIzj0C + AQYJKyQDAwIIAQEHA0IABG+raY8OSxIEfrDwz4K4K1HXLXbd0ZzAKtD9SUDtSexn + fsai8lkY8rM59TLky//HB8QDkyZewRPXClwpXCrj5HOjgZ4wgZswHQYDVR0OBBYE + FAeQMy11U15/+Mg3v37JJldo3zjSMEIGCCsGAQUFBwEBBDYwNDAyBggrBgEFBQcw + AYYmaHR0cDovL29jc3Aucm9vdC1jYS50aS1kaWVuc3RlLmRlL29jc3AwDwYDVR0T + AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFQYDVR0gBA4wDDAKBggqghQATASB + IzAKBggqhkjOPQQDAgNIADBFAiEAo4kNteSBVR4ovNeTBhkiSXsWzdRC0tQeMfIt + sE0s7/8CIDZ3EQxclVBV3huM8Bzl9ePbNsV+Lvnjv+Fo1om5+xJ2 + -----END CERTIFICATE----- + """.trimIndent() + val Base64 by lazy { x509PEMCertificateAsBase64(data) } + val X509Certificate by lazy { x509Certificate(data) } + } +} + +object TestCrypto { + const val CertPublicKeyX = "8634212830dad457ca05305e6687134166b9c21a65ffebf555f4e75dfb048888" + const val CertPublicKeyY = "66e4b6843624cbda43c97ea89968bc41fd53576f82c03efa7d601b9facac2b29" + + const val Message = "Hallo Test" + + const val EccPrivateKey = "5bbba34d47502bd588ed680dfa2309ca375eb7a35ddbbd67cc7f8b6b687a1c1d" + const val EphemeralPublicKeyX = + "754e548941e5cd073fed6d734578a484be9f0bbfa1b6fa3168ed7ffb22878f0f" + const val EphemeralPublicKeyY = + "9aef9bbd932a020d8828367bd080a3e72b36c41ee40c87253f9b1b0beb8371bf" + + const val IVBytes = "257db4604af8ae0dfced37ce" + val CipherText = + "01 754e548941e5cd073fed6d734578a484be9f0bbfa1b6fa3168ed7ffb22878f0f 9aef9bbd932a020d8828367bd080a3e72b36c41ee40c87253f9b1b0beb8371bf 257db4604af8ae0dfced37ce 86c2b491c7a8309e750b 4e6e307219863938c204dfe85502ee0a".replace( + " ", + "" + ) +} diff --git a/android/src/test/java/de/gematik/ti/erp/app/vau/UtilsTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/UtilsTest.kt similarity index 98% rename from android/src/test/java/de/gematik/ti/erp/app/vau/UtilsTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/UtilsTest.kt index bd03ab45..aa980eb1 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/vau/UtilsTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/vau/UtilsTest.kt @@ -19,7 +19,7 @@ package de.gematik.ti.erp.app.vau import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test class UtilsTest { @Test diff --git a/common/src/commonTest/resources/audit_events_bundle.json b/common/src/commonTest/resources/audit_events_bundle.json new file mode 100644 index 00000000..f848bc24 --- /dev/null +++ b/common/src/commonTest/resources/audit_events_bundle.json @@ -0,0 +1,3689 @@ +{ + "id": "bca172dc-495c-4e19-9c7b-7977739d9ce1", + "type": "searchset", + "timestamp": "2022-08-17T12:59:27.432+00:00", + "resourceType": "Bundle", + "total": 516, + "entry": [ + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-6820-a140-abdb-34aa9f2ab6ea", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-6820-a140-abdb-34aa9f2ab6ea", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:44:15.816+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "MedicationDispense" + }, + "name": "X764228532", + "description": "+" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-6baa-c200-977d-c37f5d60444b", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-6baa-c200-977d-c37f5d60444b", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:45:15.200+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "MedicationDispense" + }, + "name": "X764228532", + "description": "+" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-75dc-6850-9729-d94c0839ab3b", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-75dc-6850-9729-d94c0839ab3b", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Praxis Rainer Graf d' AgóstinoTEST-ONLY hat das Rezept mit der ID 169.000.000.000.026.84 eingestellt.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "update" + } + ], + "action": "U", + "recorded": "2022-01-13T15:48:06.226+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Praxis Rainer Graf d' AgóstinoTEST-ONLY", + "requestor": false, + "who": { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "1-SMC-B-Testkarte-883110000129077" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84/$activate", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-75de-1600-57ea-ac5e4352cffd", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-75de-1600-57ea-ac5e4352cffd", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:48:06.336+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "MedicationDispense" + }, + "name": "X764228532", + "description": "+" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-75fb-e2e8-e1d6-d0922b423e52", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-75fb-e2e8-e1d6-d0922b423e52", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Apotheke am SportzentrumTEST-ONLY hat das Rezept mit der ID 169.000.000.000.026.84 angenommen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "update" + } + ], + "action": "U", + "recorded": "2022-01-13T15:48:08.289+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Apotheke am SportzentrumTEST-ONLY", + "requestor": false, + "who": { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-SMC-B-Testkarte-883110000129068" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84/$accept", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-79a7-dbb8-8760-bc38d61fe8b3", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-79a7-dbb8-8760-bc38d61fe8b3", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:49:09.891+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-79b3-bac8-b87e-55f4c1c3ef64", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-79b3-bac8-b87e-55f4c1c3ef64", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:49:10.669+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "MedicationDispense" + }, + "name": "X764228532", + "description": "+" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-862a-e830-e470-120f0137c54e", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-862a-e830-e470-120f0137c54e", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:52:39.806+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-8638-d2b0-de8e-3d7b8d0ea121", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-8638-d2b0-de8e-3d7b8d0ea121", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:52:40.718+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "MedicationDispense" + }, + "name": "X764228532", + "description": "+" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-89fe-8188-bc8d-a0ec199fcc22", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-89fe-8188-bc8d-a0ec199fcc22", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:53:44.005+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-8a08-ca58-67ee-803b22237b89", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-8a08-ca58-67ee-803b22237b89", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat eine Liste mit Medikament-Informationen heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T15:53:44.679+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "MedicationDispense" + }, + "name": "X764228532", + "description": "+" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-a2a3-38c8-84a0-c9aa7b803dad", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-a2a3-38c8-84a0-c9aa7b803dad", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Praxis Rainer Graf d' AgóstinoTEST-ONLY hat das Rezept mit der ID 160.000.000.024.934.42 eingestellt.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "update" + } + ], + "action": "U", + "recorded": "2022-01-13T16:00:37.453+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Praxis Rainer Graf d' AgóstinoTEST-ONLY", + "requestor": false, + "who": { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "1-SMC-B-Testkarte-883110000129077" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42/$activate", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-a2d5-31c0-2e27-099ee03caf03", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-a2d5-31c0-2e27-099ee03caf03", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Apotheke am SportzentrumTEST-ONLY hat das Rezept mit der ID 160.000.000.024.934.42 angenommen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "update" + } + ], + "action": "U", + "recorded": "2022-01-13T16:00:40.728+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Apotheke am SportzentrumTEST-ONLY", + "requestor": false, + "who": { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-SMC-B-Testkarte-883110000129068" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42/$accept", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-a678-6090-f86e-c018030bb52b", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-a678-6090-f86e-c018030bb52b", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T16:01:41.754+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-a67a-6c00-17f8-200346c3973f", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-a67a-6c00-17f8-200346c3973f", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T16:01:41.888+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-a71f-4ef0-1741-3027c8c7a8c5", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-a71f-4ef0-1741-3027c8c7a8c5", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T16:01:52.694+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f56-aae3-ec58-dccd-224d94efbd90", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f56-aae3-ec58-dccd-224d94efbd90", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-13T16:02:55.911+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f6c-d5d5-e360-4300-38f5ae1d86c0", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f6c-d5d5-e360-4300-38f5ae1d86c0", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-14T18:29:45.692+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb7f6c-dadb-da68-267e-7dbd9af054c6", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb7f6c-dadb-da68-267e-7dbd9af054c6", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-14T18:31:09.969+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb806e-25ea-aa50-dc33-69ee2b772837", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb806e-25ea-aa50-dc33-69ee2b772837", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-27T13:28:55.826+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb806e-25ec-fff8-6ef7-6eb09e0f8616", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb806e-25ec-fff8-6ef7-6eb09e0f8616", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-27T13:28:55.979+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bc-b302-e350-c0f6-04896f9af888", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bc-b302-e350-c0f6-04896f9af888", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T11:11:50.450+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bc-b303-9ae8-7e6d-cf503ab1af02", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bc-b303-9ae8-7e6d-cf503ab1af02", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T11:11:50.497+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bc-b50a-b588-c468-aaab730ad331", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bc-b50a-b588-c468-aaab730ad331", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T11:12:24.517+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bc-b553-43c0-0672-adced6d59184", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bc-b553-43c0-0672-adced6d59184", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T11:12:29.272+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bc-bd84-4b98-37ab-a8623275b11e", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bc-bd84-4b98-37ab-a8623275b11e", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T11:14:46.703+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80be-26d0-0280-9b6b-cf6f5bf5fb98", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80be-26d0-0280-9b6b-cf6f5bf5fb98", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T12:55:48.240+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80be-26d0-fc80-0c9b-e1a0a5fb405c", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80be-26d0-fc80-0c9b-e1a0a5fb405c", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T12:55:48.304+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bf-d1b6-ff40-af0a-d2cd73e0b0fd", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bf-d1b6-ff40-af0a-d2cd73e0b0fd", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T14:55:10.472+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bf-d1c0-2700-f26f-b3b294a832fd", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bf-d1c0-2700-f26f-b3b294a832fd", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T14:55:11.072+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80bf-d3ca-8590-7c96-fea24a5572cb", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80bf-d3ca-8590-7c96-fea24a5572cb", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T14:55:45.306+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80c1-1cf5-cf58-9c4e-55cf5ea1c737", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80c1-1cf5-cf58-9c4e-55cf5ea1c737", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T16:27:47.847+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80c3-7998-8d98-ccbc-bf89af705bcd", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80c3-7998-8d98-ccbc-bf89af705bcd", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T19:16:51.951+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80c3-79fc-96f8-07f1-fc38f81d8fce", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80c3-79fc-96f8-07f1-fc38f81d8fce", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T19:16:58.507+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80c3-83dc-0e38-60aa-079dcc91e494", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80c3-83dc-0e38-60aa-079dcc91e494", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T19:19:44.147+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80c3-85b6-2180-a5e6-d00ad6c31fdd", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80c3-85b6-2180-a5e6-d00ad6c31fdd", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-01-31T19:20:15.216+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80e8-820a-5ba8-4465-448092e4815b", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80e8-820a-5ba8-4465-448092e4815b", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-02T15:27:47.417+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb80e8-820b-4608-0673-1e2211550779", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb80e8-820b-4608-0673-1e2211550779", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-02T15:27:47.477+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb810e-788a-d5f0-0e41-9df6e81e1cf0", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb810e-788a-d5f0-0e41-9df6e81e1cf0", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T12:45:16.822+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb810e-788b-33b0-6bda-1781f57edcea", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb810e-788b-33b0-6bda-1781f57edcea", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T12:45:16.846+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb810e-a8b1-c928-1b47-b6623ddcf079", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb810e-a8b1-c928-1b47-b6623ddcf079", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T12:58:44.681+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb810e-a8b3-8e48-ae46-6f0e2f07e6ec", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb810e-a8b3-8e48-ae46-6f0e2f07e6ec", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T12:58:44.797+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8110-3094-6ed0-5f5a-6e9dcf22ec13", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8110-3094-6ed0-5f5a-6e9dcf22ec13", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T14:48:19.426+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8110-30ca-9a00-1ea6-b57251e60ff4", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8110-30ca-9a00-1ea6-b57251e60ff4", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T14:48:22.976+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8110-3201-8b90-1c3c-b66992c3102b", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8110-3201-8b90-1c3c-b66992c3102b", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T14:48:43.354+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8110-480e-a418-7ece-6dba2c004941", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8110-480e-a418-7ece-6dba2c004941", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T14:54:53.311+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8110-4938-7a30-7a21-664d1d720498", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8110-4938-7a30-7a21-664d1d720498", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T14:55:12.830+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8110-4948-7408-6610-779c2c53061e", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8110-4948-7408-6610-779c2c53061e", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-04T14:55:13.877+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8162-b73c-3330-7870-5579b9c2801e", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8162-b73c-3330-7870-5579b9c2801e", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 169.000.000.000.026.84 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-08T17:15:45.886+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/169.000.000.000.026.84", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "169.000.000.000.026.84" + } + }, + "name": "X764228532", + "description": "169.000.000.000.026.84" + } + ] + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://example.com/AuditEvent/01eb8162-b73d-63e0-1936-feaf74abdb35", + "resource": { + "resourceType": "AuditEvent", + "id": "01eb8162-b73d-63e0-1936-feaf74abdb35", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxAuditEvent|1.1.1" + ] + }, + "text": { + "status": "generated", + "div": "
    Zacharias Zebra hat das Rezept mit der ID 160.000.000.024.934.42 heruntergeladen.
    " + }, + "language": "de", + "type": { + "system": "http://terminology.hl7.org/CodeSystem/audit-event-type", + "code": "rest" + }, + "subtype": [ + { + "system": "http://hl7.org/fhir/restful-interaction", + "code": "read" + } + ], + "action": "R", + "recorded": "2022-02-08T17:15:45.964+00:00", + "outcome": "0", + "agent": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/extra-security-role-type", + "code": "humanuser", + "display": "Human User" + } + ] + }, + "name": "Zacharias Zebra", + "requestor": false, + "who": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X764228532" + } + } + } + ], + "source": { + "site": "E-Rezept Fachdienst", + "observer": { + "reference": "Device/1" + } + }, + "entity": [ + { + "what": { + "reference": "Task/160.000.000.024.934.42", + "identifier": { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.024.934.42" + } + }, + "name": "X764228532", + "description": "160.000.000.024.934.42" + } + ] + }, + "search": { + "mode": "match" + } + } + ], + "link": [ + { + "relation": "next", + "url": "https://example.com/AuditEvent?_count=50&__offset=50" + }, + { + "relation": "self", + "url": "https://example.com/AuditEvent" + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/communications_bundle.json b/common/src/commonTest/resources/communications_bundle.json new file mode 100644 index 00000000..da9a702f --- /dev/null +++ b/common/src/commonTest/resources/communications_bundle.json @@ -0,0 +1,870 @@ +{ + "id": "7ee957aa-34d3-4013-9804-bd5634580da5", + "type": "searchset", + "timestamp": "2022-08-19T11:32:23.113+00:00", + "resourceType": "Bundle", + "total": 15, + "entry": [ + { + "fullUrl": "https://www.example.com/Communication/01eb8d02-199b-3080-fe9e-ef29caeda984", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.926.11" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"shipment\" , \"info_text\":\"11 Info\\/Para + HRcode\\/Para + DMC\\/noPara + URL\\/noPara\" , \"pickUpCodeHR\":\"T11__R03\" , \"pickUpCodeDMC\":\"\" , \"url\":\"\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T15:02:03.984+00:00", + "id": "01eb8d02-199b-3080-fe9e-ef29caeda984", + "received": "2022-07-06T15:03:39.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d02-0bba-b300-06b8-92fefaa88ee4", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.926.11" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\": \"1\", \"supplyOptionsType\": \"onPremise\", \"info_text\": \"\", \"pickUpCodeHR\": \"\" , \"url\": \"\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:58:11.168+00:00", + "id": "01eb8d02-0bba-b300-06b8-92fefaa88ee4", + "received": "2022-07-06T14:59:42.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-cbca-fe30-1686-c48e4e4c37d3", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.945.51" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"info_text\":\"\" , \"pickUpCodeHR\" : \"\" , \"pickUpCodeDMC\" : \"\" , \"url\": \"\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:40:18.494+00:00", + "id": "01eb8d01-cbca-fe30-1686-c48e4e4c37d3", + "received": "2022-07-06T14:40:39.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-9a8d-99b8-9277-24b66fb07635", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.926.11/$accept?ac=84520a0508b8ed6a07c86f71d9e4f24da2c1c314c69ce0e642cb7fe49c37842e" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-TEST-TID" + } + } + ], + "payload": [ + { + "contentString": "{\"version\":\"1\",\"supplyOptionsType\":\"shipment\",\"name\":\"Prinzessin Lars Graf Freiherr von Schinder\",\"address\":[\"Siegburger Str. 155\",\"\",\"51105 Köln\"],\"hint\":\"\",\"phone\":\"01711111111\"}" + } + ], + "sender": { + "identifier": { + "value": "X110535768", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + }, + "sent": "2022-07-06T14:26:32.387+00:00", + "id": "01eb8d01-9a8d-99b8-9277-24b66fb07635" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-e974-77e0-4b92-36e9573c0ce9", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.933.87" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"info_text\":\"\" , \"pickUpCodeDMC\":\"Test_06___Rezept_02___abcdefg12345\" , \"url\":\"\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:48:36.140+00:00", + "id": "01eb8d01-e974-77e0-4b92-36e9573c0ce9", + "received": "2022-07-06T14:50:26.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-9741-2220-90e3-6ea2e74c9f84", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.945.51/$accept?ac=6f52c7ff158128b640249409585f1ef0476f46b0c8f72b564ebba4f2cadd3757" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-TEST-TID" + } + } + ], + "payload": [ + { + "contentString": "{\"version\":\"1\",\"supplyOptionsType\":\"onPremise\",\"name\":\"Prinzessin Lars Graf Freiherr von Schinder\",\"address\":[\"Siegburger Str. 155\",\"\",\"51105 Köln\"],\"hint\":\"\",\"phone\":\"01711111111\"}" + } + ], + "sender": { + "identifier": { + "value": "X110535768", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + }, + "sent": "2022-07-06T14:25:37.044+00:00", + "id": "01eb8d01-9741-2220-90e3-6ea2e74c9f84" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d02-1e3c-08d0-04b7-50d050640566", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.926.11" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"shipment\" , \"info_text\":\"12 Info\\/Para + HRcode\\/noPara + noDMC\\/noPara + noURL\\/noPara\" , \"pickUpCodeHR\":\"\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T15:03:21.634+00:00", + "id": "01eb8d02-1e3c-08d0-04b7-50d050640566", + "received": "2022-07-06T15:03:39.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-fa80-fc48-c50a-391c122d6a64", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.933.87" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"pickUpCodeDMC\":\"Test_07___Rezept_02___abcdefg12345\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:53:22.173+00:00", + "id": "01eb8d01-fa80-fc48-c50a-391c122d6a64", + "received": "2022-07-06T14:56:23.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-ff62-b210-7825-a0abdd07fda3", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.933.87" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"info_text\":\"08 Info\\/Para + HRcode\\/noPara + DMC\\/noPara + noURL\\/noPara\" , \"pickUpCodeHR\":\"\" , \"pickUpCodeDMC\":\"\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:54:44.074+00:00", + "id": "01eb8d01-ff62-b210-7825-a0abdd07fda3", + "received": "2022-07-06T14:56:23.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-d0b9-69e0-a243-05f8969f051e", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.945.51" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"pickUpCodeHR\":\"T03__R01\" , \"pickUpCodeDMC\":\"Test_03___Rezept_01___abcdefg12345\" , \"url\":\"https:\\/\\/www.example.com\\/forest\\/33\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:41:41.228+00:00", + "id": "01eb8d01-d0b9-69e0-a243-05f8969f051e", + "received": "2022-07-06T14:43:01.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-e34a-a600-c001-ad607345bb8c", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.933.87" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"info_text\":\"05 Info\\/Para + HRcode\\/noPara + DMC\\/Para + URL\\/Para\",\"pickUpCodeHR\":\"\" , \"pickUpCodeDMC\":\"Test_05___Rezept_02___abcdefg12345\" , \"url\": \"https:\\/\\/www.example.com\\/forest\\/33\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:46:52.736+00:00", + "id": "01eb8d01-e34a-a600-c001-ad607345bb8c", + "received": "2022-07-06T14:47:19.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-98c7-1640-86b4-f6fc95dc6967", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.933.87/$accept?ac=a187c87b31facfcab692b459a89d13e0f6f646b41a11f945ac36439a6cdbe4f5" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-TEST-TID" + } + } + ], + "payload": [ + { + "contentString": "{\"version\":\"1\",\"supplyOptionsType\":\"delivery\",\"name\":\"Prinzessin Lars Graf Freiherr von Schinder\",\"address\":[\"Siegburger Str. 155\",\"\",\"51105 Köln\"],\"hint\":\"\",\"phone\":\"01711111111\"}" + } + ], + "sender": { + "identifier": { + "value": "X110535768", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + }, + "sent": "2022-07-06T14:26:02.600+00:00", + "id": "01eb8d01-98c7-1640-86b4-f6fc95dc6967" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-de72-5a60-919a-0044a88ab115", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.945.51" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"info_text\":\"04 Info_Para + HRcode_Para + DMC_noPara + URL_Para\" , \"pickUpCodeHR\":\"T04__R01\" , \"pickUpCodeDMC\":\"\" , \"url\": \"https:\\/\\/www.example.com\\/forest\\/33\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:45:31.452+00:00", + "id": "01eb8d01-de72-5a60-919a-0044a88ab115", + "received": "2022-07-06T14:45:47.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d02-11dc-6aa8-07aa-e59288b6a24f", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.926.11" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"shipment\" , \"info_text\":\"10 Info\\/Para + HRcode\\/Para + DMC\\/Para + URL\\/Para\" , \"pickUpCodeHR\":\"T10__R03\" , \"pickUpCodeDMC\":\"Test_10___Rezept_03___abcdefg12345\" , \"url\":\"https:\\/\\/www.example.com\\/forest\\/33\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:59:54.041+00:00", + "id": "01eb8d02-11dc-6aa8-07aa-e59288b6a24f", + "received": "2022-07-06T15:00:15.000+00:00" + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://www.example.com/Communication/01eb8d01-c585-3040-c1f1-d749d8e62add", + "resource": { + "resourceType": "Communication", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply|1.1.1" + ] + }, + "basedOn": [ + { + "reference": "Task/160.000.000.030.945.51" + } + ], + "status": "unknown", + "recipient": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535768" + } + } + ], + "payload": [ + { + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/SupplyOptionsType", + "extension": [ + { + "url": "onPremise", + "valueBoolean": true + }, + { + "url": "delivery", + "valueBoolean": true + }, + { + "url": "shipment", + "valueBoolean": true + } + ] + } + ], + "contentString": "{\"version\":\"1\" , \"supplyOptionsType\":\"onPremise\" , \"info_text\":\"01 Info\\/Para + HRcode\\/Para + DMC\\/Para + URL\\/Para\" , \"pickUpCodeHR\":\"T01__R01\" , \"pickUpCodeDMC\":\"Test_01___Rezept_01___abcdefg12345\" , \"url\":\"https:\\/\\/www.example.com\\/forest\\/33\"}" + } + ], + "sender": { + "identifier": { + "value": "3-TEST-TID", + "system": "https://gematik.de/fhir/NamingSystem/TelematikID" + } + }, + "sent": "2022-07-06T14:38:33.256+00:00", + "id": "01eb8d01-c585-3040-c1f1-d749d8e62add", + "received": "2022-07-06T14:40:39.000+00:00" + }, + "search": { + "mode": "match" + } + } + ], + "link": [ + { + "relation": "self", + "url": "https://www.example.com/Communication" + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/ingredient.json b/common/src/commonTest/resources/fhir/ingredient.json new file mode 100644 index 00000000..04763857 --- /dev/null +++ b/common/src/commonTest/resources/fhir/ingredient.json @@ -0,0 +1,20 @@ +{ + "itemCodeableConcept": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ask", + "code": "37197" + } + ], + "text": "Wirkstoff Paulaner Weissbier" + }, + "strength": { + "numerator": { + "value": 1, + "unit": "Maß" + }, + "denominator": { + "value": 1 + } + } +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/insurance_information.json b/common/src/commonTest/resources/fhir/insurance_information.json new file mode 100644 index 00000000..e5976e22 --- /dev/null +++ b/common/src/commonTest/resources/fhir/insurance_information.json @@ -0,0 +1,60 @@ +{ + "resourceType": "Coverage", + "id": "da80211e-61ee-458e-a651-87370b6ec30c", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Coverage|1.0.3" + ] + }, + "extension": [ + { + "url": "http://fhir.de/StructureDefinition/gkv/besondere-personengruppe", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_PERSONENGRUPPE", + "code": "00" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/dmp-kennzeichen", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DMP", + "code": "00" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/wop", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_ITA_WOP", + "code": "38" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/versichertenart", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_VERSICHERTENSTATUS", + "code": "3" + } + } + ], + "status": "active", + "type": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/versicherungsart-de-basis", + "code": "GKV" + } + ] + }, + "beneficiary": { + "reference": "Patient/ce4104af-b86b-4664-afee-1b5fc3ac8acf" + }, + "payor": [ + { + "identifier": { + "system": "http://fhir.de/NamingSystem/arge-ik/iknr", + "value": "101570104" + }, + "display": "HEK" + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/medication_compounding.json b/common/src/commonTest/resources/fhir/medication_compounding.json new file mode 100644 index 00000000..fac7c2d7 --- /dev/null +++ b/common/src/commonTest/resources/fhir/medication_compounding.json @@ -0,0 +1,71 @@ +{ + "resourceType": "Medication", + "id": "5cd916fc-2ae2-4148-b016-911ec5f4c0a5", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Compounding|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "00" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + } + ], + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Type", + "code": "rezeptur" + } + ] + }, + "form": { + "text": "Lösung" + }, + "amount": { + "numerator": { + "value": 100, + "unit": "ml" + }, + "denominator": { + "value": 1 + } + }, + "ingredient": [ + { + "itemCodeableConcept": { + "text": "1_3 Graf 02.08.2022" + }, + "strength": { + "numerator": { + "value": 5, + "unit": "g" + }, + "denominator": { + "value": 1 + } + } + }, + { + "itemCodeableConcept": { + "text": "2-propanol 70 %" + }, + "strength": { + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Ingredient_Amount", + "valueString": "Ad 100 g" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/medication_dispense.json b/common/src/commonTest/resources/fhir/medication_dispense.json new file mode 100644 index 00000000..051623d8 --- /dev/null +++ b/common/src/commonTest/resources/fhir/medication_dispense.json @@ -0,0 +1,94 @@ +{ + "resourceType": "MedicationDispense", + "id": "160.000.000.031.686.59", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxMedicationDispense" + ] + }, + "contained": [ + { + "resourceType": "Medication", + "id": "65cab1df-2514-4395-910f-dc4e247a84ca", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "01" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + }, + { + "url": "http://fhir.de/StructureDefinition/normgroesse", + "valueCode": "Sonstiges" + } + ], + "code": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ifa/pzn", + "code": "06491772" + } + ], + "text": "Defamipin" + }, + "form": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM", + "code": "FET" + } + ] + }, + "amount": { + "numerator": { + "value": 18, + "unit": "Stk" + }, + "denominator": { + "value": 1 + } + }, + "batch": { + "lotNumber": "8521037577", + "expirationDate": "2023-05-02T08:26:06+02:00" + } + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.031.686.59" + } + ], + "status": "completed", + "medicationReference": { + "reference": "#65cab1df-2514-4395-910f-dc4e247a84ca" + }, + "subject": { + "identifier": { + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535541" + } + }, + "performer": [ + { + "actor": { + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-SMC-B-Testkarte-883110000116873" + } + } + } + ], + "whenHandedOver": "2022-07-12" +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/medication_freetext.json b/common/src/commonTest/resources/fhir/medication_freetext.json new file mode 100644 index 00000000..e65bf36b --- /dev/null +++ b/common/src/commonTest/resources/fhir/medication_freetext.json @@ -0,0 +1,31 @@ +{ + "resourceType": "Medication", + "id": "0d93504e-c6a7-47c1-8ad5-b0c5cf1b8920", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_FreeText|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "00" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + } + ], + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Type", + "code": "freitext" + } + ], + "text": "Freitext" + } +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/medication_ingredient.json b/common/src/commonTest/resources/fhir/medication_ingredient.json new file mode 100644 index 00000000..8b399f09 --- /dev/null +++ b/common/src/commonTest/resources/fhir/medication_ingredient.json @@ -0,0 +1,60 @@ +{ + "resourceType": "Medication", + "id": "86fa62c7-06a0-418e-ba26-e99baa053c07", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Ingredient|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "00" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + }, + { + "url": "http://fhir.de/StructureDefinition/normgroesse", + "valueCode": "N1" + } + ], + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Type", + "code": "wirkstoff" + } + ] + }, + "form": { + "text": "Flüssigkeiten" + }, + "ingredient": [ + { + "itemCodeableConcept": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ask", + "code": "37197" + } + ], + "text": "Wirkstoff Paulaner Weissbier" + }, + "strength": { + "numerator": { + "value": 1, + "unit": "Maß" + }, + "denominator": { + "value": 1 + } + } + } + ] + +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/medication_pzn.json b/common/src/commonTest/resources/fhir/medication_pzn.json new file mode 100644 index 00000000..bc96d3fe --- /dev/null +++ b/common/src/commonTest/resources/fhir/medication_pzn.json @@ -0,0 +1,54 @@ +{ + "resourceType": "Medication", + "id": "b7dd5ddb-b5ad-4b04-af11-6d2a354bce0c", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "00" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + }, + { + "url": "http://fhir.de/StructureDefinition/normgroesse", + "valueCode": "N1" + } + ], + "code": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ifa/pzn", + "code": "00427833" + } + ], + "text": "Ich bin in Einlösung" + }, + "form": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM", + "code": "IHP" + } + ] + }, + "amount": { + "numerator": { + "value": 1, + "unit": "Diskus", + "system": "http://unitsofmeasure.org", + "code": "{tbl}" + }, + "denominator": { + "value": 1 + } + } +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/medication_request.json b/common/src/commonTest/resources/fhir/medication_request.json new file mode 100644 index 00000000..59c95355 --- /dev/null +++ b/common/src/commonTest/resources/fhir/medication_request.json @@ -0,0 +1,118 @@ +{ + "resourceType": "MedicationRequest", + "id": "aadf0a82-e6e0-414d-b2b6-e46c60be2adb", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Prescription|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_BVG", + "valueBoolean": true + }, + { + "url":"https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident", + "extension":[ + { + "url":"unfallkennzeichen","valueCoding":{ + "system":"https://fhir.kbv.de/CodeSystem/KBV_CS_FOR_Ursache_Type","code":"2" + } + },{ + "url":"unfallbetrieb","valueString":"Dummy-Betrieb" + }, + { + "url":"unfalltag","valueDate":"2022-06-29" + } + ] + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_EmergencyServicesFee", + "valueBoolean": false + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Multiple_Prescription", + "extension": [ + { + "url": "Kennzeichen", + "valueBoolean": true + }, + { + "url": "Nummerierung", + "valueRatio": { + "numerator": { + "value": 1 + }, + "denominator": { + "value": 4 + } + } + }, + { + "url": "Zeitraum", + "valuePeriod": { + "start": "2022-08-17", + "end": "2022-11-25" + } + }, + { + "url": "ID", + "valueIdentifier": { + "system": "urn:ietf:rfc:3986", + "value": "urn:uuid:24e2e10d-e962-4d1c-be4f-8760e690a5f0" + } + } + ] + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_StatusCoPayment", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_StatusCoPayment", + "code": "2" + } + } + ], + "status": "active", + "intent": "order", + "medicationReference": { + "reference": "Medication/367b56f2-b71a-454e-80d0-0788f4a852e0" + }, + "subject": { + "reference": "Patient/312824b6-a91f-4dd1-98fb-e44cde3a2d68" + }, + "authoredOn": "2022-08-17", + "requester": { + "reference": "Practitioner/70a72d72-3168-4700-8b6f-97c7da4b8d65" + }, + "insurance": [ + { + "reference": "Coverage/e272419e-cf6e-46d2-9299-e35a7ad2000d" + } + ], + "dosageInstruction": [ + { + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_DosageFlag", + "valueBoolean": true + } + ], + "text": "1-2-1-2-0" + } + ], + "note": [ + { + "text": "Bitte laengliche Tabletten." + } + ], + "dispenseRequest": { + "quantity": { + "value": 12, + "system": "http://unitsofmeasure.org", + "code": "{Package}" + } + }, + "substitution": { + "allowedBoolean": true + } +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/multi_prescription_info.json b/common/src/commonTest/resources/fhir/multi_prescription_info.json new file mode 100644 index 00000000..83dda099 --- /dev/null +++ b/common/src/commonTest/resources/fhir/multi_prescription_info.json @@ -0,0 +1,34 @@ +{ + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Multiple_Prescription", + "extension": [ + { + "url": "Kennzeichen", + "valueBoolean": true + }, + { + "url": "Nummerierung", + "valueRatio": { + "numerator": { + "value": 1 + }, + "denominator": { + "value": 4 + } + } + }, + { + "url": "Zeitraum", + "valuePeriod": { + "start": "2022-08-17", + "end": "2022-11-25" + } + }, + { + "url": "ID", + "valueIdentifier": { + "system": "urn:ietf:rfc:3986", + "value": "urn:uuid:24e2e10d-e962-4d1c-be4f-8760e690a5f0" + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/organization.json b/common/src/commonTest/resources/fhir/organization.json new file mode 100644 index 00000000..5594f8af --- /dev/null +++ b/common/src/commonTest/resources/fhir/organization.json @@ -0,0 +1,63 @@ +{ + "resourceType": "Organization", + "id": "5d3f4ac0-2b44-4d48-b363-e63efa72973b", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Organization|1.0.3" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "BSNR" + } + ] + }, + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR", + "value": "721111100" + } + ], + "name": "MVZ", + "telecom": [ + { + "system": "phone", + "value": "0301234567" + }, + { + "system": "fax", + "value": "030123456789" + }, + { + "system": "email", + "value": "mvz@e-mail.de" + } + ], + "address": [ + { + "type": "both", + "line": [ + "Herbert-Lewin-Platz 2" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "2" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Herbert-Lewin-Platz" + } + ] + } + ], + "city": "Berlin", + "postalCode": "10623", + "country": "D" + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/patient.json b/common/src/commonTest/resources/fhir/patient.json new file mode 100644 index 00000000..25405a07 --- /dev/null +++ b/common/src/commonTest/resources/fhir/patient.json @@ -0,0 +1,87 @@ +{ + "resourceType": "Patient", + "id": "fc0d145b-09b4-4af6-b477-935c1862ac7f", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient|1.0.3" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/identifier-type-de-basis", + "code": "GKV" + } + ] + }, + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110535541" + } + ], + "name": [ + { + "use": "official", + "family": "Graf Freiherr von Schinder", + "_family": { + "extension": [ + { + "url": "http://fhir.de/StructureDefinition/humanname-namenszusatz", + "valueString": "Graf Freiherr" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", + "valueString": "von" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", + "valueString": "Schinder" + } + ] + }, + "given": [ + "Lars" + ], + "prefix": [ + "Prinzessin" + ], + "_prefix": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-EN-qualifier", + "valueCode": "AC" + } + ] + } + ] + } + ], + "birthDate": "1964-04-04", + "address": [ + { + "type": "both", + "line": [ + "Siegburger Str. 155" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "155" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Siegburger Str." + } + ] + } + ], + "city": "Köln", + "postalCode": "51105", + "country": "D" + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/practitioner.json b/common/src/commonTest/resources/fhir/practitioner.json new file mode 100644 index 00000000..36e0c453 --- /dev/null +++ b/common/src/commonTest/resources/fhir/practitioner.json @@ -0,0 +1,70 @@ +{ + "resourceType": "Practitioner", + "id": "667ffd79-42a3-4002-b7ca-6b9098f20ccb", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Practitioner|1.0.3" + ] + }, + "identifier": [ + { + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "LANR" + } + ] + }, + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_ANR", + "value": "987654423" + } + ], + "name": [ + { + "use": "official", + "family": "Schneider", + "_family": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", + "valueString": "Schneider" + } + ] + }, + "given": [ + "Emma" + ], + "prefix": [ + "Dr. med." + ], + "_prefix": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-EN-qualifier", + "valueCode": "AC" + } + ] + } + ] + } + ], + "qualification": [ + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_FOR_Qualification_Type", + "code": "00" + } + ] + } + }, + { + "code": { + "text": "Fachärztin für Innere Medizin" + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/quantity.json b/common/src/commonTest/resources/fhir/quantity.json new file mode 100644 index 00000000..fd643175 --- /dev/null +++ b/common/src/commonTest/resources/fhir/quantity.json @@ -0,0 +1,6 @@ +{ + "value": 12, + "unit": "TAB", + "system": "http://unitsofmeasure.org", + "code": "{tbl}" +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/ratio.json b/common/src/commonTest/resources/fhir/ratio.json new file mode 100644 index 00000000..96c2adc0 --- /dev/null +++ b/common/src/commonTest/resources/fhir/ratio.json @@ -0,0 +1,11 @@ +{ + "numerator": { + "value": 12, + "unit": "TAB", + "system": "http://unitsofmeasure.org", + "code": "{tbl}" + }, + "denominator": { + "value": 1 + } +} \ No newline at end of file diff --git a/common/src/commonTest/resources/fhir/task.json b/common/src/commonTest/resources/fhir/task.json new file mode 100644 index 00000000..3996614a --- /dev/null +++ b/common/src/commonTest/resources/fhir/task.json @@ -0,0 +1,76 @@ +{ + "resourceType": "Task", + "id": "160.000.000.029.982.30", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.029.982.30" + }, + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/AccessCode", + "value": "dd23212d35d14ccde351f9a1077f3d9508dcb8629882627ec16a22ea86144290" + } + ], + "intent": "order", + "status": "completed", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-09-09" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2022-07-07" + } + ], + "authoredOn": "2022-06-09T11:50:23.223+00:00", + "lastModified": "2022-06-09T11:57:37.923+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "for": { + "identifier": { + "value": "X110466481", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + }, + "input": [ + { + "type": { + "coding": [ + { + "system": "https://gematik.de/fhir/CodeSystem/Documenttype", + "code": "2" + } + ] + }, + "valueReference": { + "reference": "a01e7500-0000-0000-0002-000000000000" + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/idp/discovery-doc.jwt b/common/src/commonTest/resources/idp/discovery-doc.jwt new file mode 100644 index 00000000..0dd64485 --- /dev/null +++ b/common/src/commonTest/resources/idp/discovery-doc.jwt @@ -0,0 +1 @@ +eyJhbGciOiJCUDI1NlIxIiwia2lkIjoiZGlzY1NpZyIsIng1YyI6WyJNSUlDc1RDQ0FsaWdBd0lCQWdJSEFic3NxUWhxT3pBS0JnZ3Foa2pPUFFRREFqQ0JoREVMTUFrR0ExVUVCaE1DUkVVeEh6QWRCZ05WQkFvTUZtZGxiV0YwYVdzZ1IyMWlTQ0JPVDFRdFZrRk1TVVF4TWpBd0JnTlZCQXNNS1V0dmJYQnZibVZ1ZEdWdUxVTkJJR1JsY2lCVVpXeGxiV0YwYVd0cGJtWnlZWE4wY25WcmRIVnlNU0F3SGdZRFZRUUREQmRIUlUwdVMwOU5VQzFEUVRFd0lGUkZVMVF0VDA1TVdUQWVGdzB5TVRBeE1UVXdNREF3TURCYUZ3MHlOakF4TVRVeU16VTVOVGxhTUVreEN6QUpCZ05WQkFZVEFrUkZNU1l3SkFZRFZRUUtEQjFuWlcxaGRHbHJJRlJGVTFRdFQwNU1XU0F0SUU1UFZDMVdRVXhKUkRFU01CQUdBMVVFQXd3SlNVUlFJRk5wWnlBek1Gb3dGQVlIS29aSXpqMENBUVlKS3lRREF3SUlBUUVIQTBJQUJJWVpud2lHQW41UVlPeDQzWjhNd2FaTEQzci9iejZCVGNRTzVwYmV1bTZxUXpZRDVkRENjcml3L1ZOUFBaQ1F6WFFQZzRTdFd5eTVPT3E5VG9nQkVtT2pnZTB3Z2Vvd0RnWURWUjBQQVFIL0JBUURBZ2VBTUMwR0JTc2tDQU1EQkNRd0lqQWdNQjR3SERBYU1Bd01Da2xFVUMxRWFXVnVjM1F3Q2dZSUtvSVVBRXdFZ2dRd0lRWURWUjBnQkJvd0dEQUtCZ2dxZ2hRQVRBU0JTekFLQmdncWdoUUFUQVNCSXpBZkJnTlZIU01FR0RBV2dCUW84UGptcWNoM3pFTkYyNXF1MXpxRHJBNFBxREE0QmdnckJnRUZCUWNCQVFRc01Db3dLQVlJS3dZQkJRVUhNQUdHSEdoMGRIQTZMeTlsYUdOaExtZGxiV0YwYVdzdVpHVXZiMk56Y0M4d0hRWURWUjBPQkJZRUZDOTRNOUxnVzQ0bE5nb0Fia1Bhb21uTGpTOC9NQXdHQTFVZEV3RUIvd1FDTUFBd0NnWUlLb1pJemowRUF3SURSd0F3UkFJZ0NnNHlaRFdteUJpcmd4emF3ei9TOERKblJGS3RZVS9ZR05sUmM3K2tCSGNDSUJ1emJhM0dzcHFTbW9QMVZ3TWVOTktOYUxzZ1Y4dk1iREpiMzBhcWFpWDEiXX0K.eyJhdXRob3JpemF0aW9uX2VuZHBvaW50IjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L3NpZ25fcmVzcG9uc2UiLCJhdXRoX3BhaXJfZW5kcG9pbnQiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvYWx0X3Jlc3BvbnNlIiwic3NvX2VuZHBvaW50IjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L3Nzb19yZXNwb25zZSIsInVyaV9wYWlyIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L3BhaXJpbmdzIiwidG9rZW5fZW5kcG9pbnQiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvdG9rZW4iLCJ1cmlfZGlzYyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODg4OC9kaXNjb3ZlcnlEb2N1bWVudCIsImlzc3VlciI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODg4OCIsImp3a3NfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2p3a3MiLCJleHAiOjE2MTYxNDM4NzYsImlhdCI6MTYxNjA1NzQ3NiwidXJpX3B1a19pZHBfZW5jIjoiaHR0cDovL2xvY2FsaG9zdDo4ODg4L2lkcEVuYy9qd2suanNvbiIsInVyaV9wdWtfaWRwX3NpZyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODg4OC9pcGRTaWcvandrLmpzb24iLCJra19hcHBfbGlzdF91cmkiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvYXBwTGlzdCIsInRoaXJkX3BhcnR5X2F1dGhvcml6YXRpb25fZW5kcG9pbnQiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODgvdGhpcmRQYXJ0eUF1dGgiLCJzdWJqZWN0X3R5cGVzX3N1cHBvcnRlZCI6WyJwYWlyd2lzZSJdLCJpZF90b2tlbl9zaWduaW5nX2FsZ192YWx1ZXNfc3VwcG9ydGVkIjpbIkJQMjU2UjEiXSwicmVzcG9uc2VfdHlwZXNfc3VwcG9ydGVkIjpbImNvZGUiXSwic2NvcGVzX3N1cHBvcnRlZCI6WyJvcGVuaWQiLCJlLXJlemVwdCJdLCJyZXNwb25zZV9tb2Rlc19zdXBwb3J0ZWQiOlsicXVlcnkiXSwiZ3JhbnRfdHlwZXNfc3VwcG9ydGVkIjpbImF1dGhvcml6YXRpb25fY29kZSJdLCJhY3JfdmFsdWVzX3N1cHBvcnRlZCI6WyJnZW1hdGlrLWVoZWFsdGgtbG9hLWhpZ2giXSwidG9rZW5fZW5kcG9pbnRfYXV0aF9tZXRob2RzX3N1cHBvcnRlZCI6WyJub25lIl0sImNvZGVfY2hhbGxlbmdlX21ldGhvZHNfc3VwcG9ydGVkIjpbIlMyNTYiXX0K.kzREKDmjMY7eBWnyjJegij4srFcIOzHyeQs_CAz4A4pzobMlTDC9QNN0S1y-b4ETx6OChyp_OuFCC_4g4clobQ \ No newline at end of file diff --git a/common/src/commonTest/resources/idp/idpCertificate.txt b/common/src/commonTest/resources/idp/idpCertificate.txt new file mode 100644 index 00000000..f5c509cd --- /dev/null +++ b/common/src/commonTest/resources/idp/idpCertificate.txt @@ -0,0 +1 @@ +MIICsTCCAligAwIBAgIHAbssqQhqOzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTUwMDAwMDBaFw0yNjAxMTUyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAzMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABIYZnwiGAn5QYOx43Z8MwaZLD3r/bz6BTcQO5pbeum6qQzYD5dDCcriw/VNPPZCQzXQPg4StWyy5OOq9TogBEmOjge0wgeowDgYDVR0PAQH/BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIUAEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYBBQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFC94M9LgW44lNgoAbkPaomnLjS8/MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAwRAIgCg4yZDWmyBirgxzawz/S8DJnRFKtYU/YGNlRc7+kBHcCIBuzba3GspqSmoP1VwMeNNKNaLsgV8vMbDJb30aqaiX1 \ No newline at end of file diff --git a/common/src/commonTest/resources/idp/sso-token.txt b/common/src/commonTest/resources/idp/sso-token.txt new file mode 100644 index 00000000..52718485 --- /dev/null +++ b/common/src/commonTest/resources/idp/sso-token.txt @@ -0,0 +1 @@ +eyJlbmMiOiJBMjU2R0NNIiwiY3R5IjoiTkpXVCIsImV4cCI6MTY1NjQxMjM5NywiYWxnIjoiZGlyIiwia2lkIjoiMDAwMSJ9..Rf1jnfEltQpf3EKY.94YhRcBHpT2RGVZJdjARRPKKrgt_3-TvDMAhC0kSqGAvXPophbZdUhzE_CutDUhpENAGmgKyY1hru5hjSxhs9gV3vLxWRLEtGARpCe5pX9jhV3znptWIkqXyVjbyEW4SXbPzOr0LDjdveEnFTTapJhui3MFbM_yd_ko9vLy0ZlnfGhuZ4oZ6Phq5G1qrGvZNymaR22rAd2W52S8lCFepbYLLzS9V39QNkldMtOv7AZ1kwJHO4UUfb9S_awY0q1RYJnroypQx-1PrjcSKf1lPz70JsIIW70AjemALPJWJOkGjJAK3sCv0q23iWzRmgABU8LAvtF3dV7i0x293by2gvMAsyP3zLbVdhVzIdKKvLi6baoxNlBU1mmbYILkrxFqGI4gngxPJ0C5iGN1dQLxkuEWo6gKZIWn-_ihIODmWzI62oypwVGyuMoS_SK_2hYCqa4c3O6HY9hp2OYiaq9NC0jMbMQVUOH81Gji_kORGNiSmlnYLRJE463Nirh9daZCUPSq_yW4GvOYrOh-AtvTlg3OMhaYBbrFYKnECzK2sQ7Lld3YoxoU8mwqBAfd9M9t5DhaNDeTFPzvnZH20WF1m0ibwSJnScRr5oxw2yfn-pvZlzEdEW4c9_FyuNrlJY0_P9I9iNWs8-jHS8WyOFXz_85P4HVkGxA4aiU3zfuNp6s-4uF-WLEkRUY4jVGbeFoXtd2ohc4rJtf_Wd1uywKKqijQhux2yuWGWjCPnY5G9ME4DI4_NH_o-Q_sRCsPf0Ls68df_Exr6LanDjIWaWBXfu7HBvHGbKQ_-Z-U3O25_u-LHx2uiyEbhtEmjbmZqB--MyZyg42JPiYP1YI8mHcO5ogSScQ2YA4rUiTmTVhXABk4Vam8AkP0CYOB6h0LUbKHRU1KRT5zJMAz8GajWnnrEaLo4nY4asKu1RWqkHpMTaDfQKX1PELTCCWjem6fVau_lBWJB1kT00a6YhjnMCGi2Rlvzq8_AHP4fUD8Nsd_bk8Lj53GZm9VuIGQOyGy_z8nLATGr9F54kXWKc2kiZxpHa30Thr-lXmUK3hNJI8m3hBzfTJRHOL6cfDqSkiiR_sFiDiONEpdnS60Vdoaxpx8AGK8CXlcVzIRp8UPDCbBwka-wtPlbYvor4bFmIerg3yPTrR5ioy07IB_cAoia9A-EsnHpro9AWcvtSQD7jaFSuiqMuFjT3dNglEm4SZFBYQF-okQv70Ib__iSDDoBK0IK_pfqS8ZX_K2XK_sJ3qphT1RGWlRL7-HqPC2pH4dw0et4iIrL_-51z-7lFtVvXHO2jjlZSVWOl2dPHTKVtfTh_1KFsom5l8PUB-HChXoE5VT5Dzsd83cPGZ4Km7VAJWUIDaHF7h1fpXt6dMxSCw7VT7gc8AhcgX8gIe3mIefk2cyfFRXuUnXW8VY0hEaSyaFIJSmgZjwVoLTr97d_KIVp8bmHVmsz8VllXIFq7HxEjouTXVxx9G_Dn2yIAQ9XV9NY2VCTz_wq5byVeSBUzTJlGeEF4IxvDTsBLwuZg-DC8cEiO8kx6auSr1OVSi21yr3S037WuYZb87aKLFITFfjkZz8q_ng2WgavaQdSond1PtxOaKZ0VuFXNoQ5Q9V1qEnqkzZul0gtElcVbGRtwA2X1i-nQ0EffckzFmfEcCVM200cDVnlhcxJc2uWMQcAK9sEdTQ-gN-87rZiumTd-PiOY7sK4JSIqpKdIO967YhZtI9d84bBexxv1NtgimeyAcdldZAuz1skOjL9iq0hhPvT5I8HPUikKbK4nLK_4lvc6I455NeHgQQnFkbiEDJQbwuKhjoW6L-NzPaUuBeL2PD5TBpc9U9M_Y8hV0p-ZIKEppB2EanZJIlwb-9fAoOVtxbaqRKyg_dZFRQ4qpbmlf9J4PRGDmRb4levthd-Fw4zad-qekwGjfEMGtQKtoB4PQ_SGbzorsdzPUU9SzSwgpgQWFYE3K4GhO4yZcr57fv3T1jVdFwW1u9V5AVAaJH_6qj8IHX-tHki8SmMN6UcgBYdzAg9IO5NNR7_HSvIAQJmh2ZTB21vZyBPYnsmpCKBqve2c-HU4J3Od4kVKu4EL9b4Z3VlYIrBZTaGFp-UXGSBemJENgYmoxitj_F_LfVFQ0CH0gOTJ312qtC9KAyiCp5bPPnajPqzIX-UpjZE3MFPBfjmNloqQwC-VjTNn2_0YZYtazOhtoNdQI97v88BRdHxGkvtdpRQbY-YeYeVPzdjgKZ-jGazoXT0MJ4bIB-zs7cha6lpCfSYRgmVQoMym2loRAdr0__ULXh_LcvAak0YYUWma5CmB5-cHQa1jAGwkzqlmD7KipP-6BPGwNM438eeF5UgRLlj7B5iVCkYMO8Fl_25SvlaV2EZt8Pj502obtz8L8K5YQVYujunZcArwlxhw6NkqXiexFpYGbzT8UscAymlVmXqYbfnsQIIKwmuSxfdAIi-7Cacd7qtbIBBzX_DZdizU6J9L9MSmvGOauObyfbQexoxfxoaBWs_eOtstmxjv1--TEcBNODrm7sltAYPZk2N7xrYDEOs1jJ0h_gjmbX6JFRmKOfaiw-tRD71ojqCtGg-ConlFefUTXqiKfPQkbs3S8wc-qK45dXcvAwi3pogR-EvFmHyxeWCaWLmnBs0m8V-SE9mXBCPcUHpO0m1_4cl8EKq1Ic-ktyZQKZ9vesMoHQ31l-lMYNqiNMgdOrPbo7Fo2uWg4FGNrzX3mNtLOJPipc10HC7abEhtM80jCZt3aRfSz72hyEZDpmPSk6IFu5jv1dsEsvNQdJcr6w7NV_VWMjZ7wNoPdYpZ6ts4bf1-mZdZOF823maP1jfE03EtieluL0m9KInqAZJ3ox3ihZ2CUlfXVnAc57xpa5WjKn3eA4u8ubIrCLvhuNa39qCh0SKZIjVThsBU5-TJud_P4IV00dpGR5W7vv4xr0B0wrjKBqR2ulqRoXFdnIc7Qya2LDCUKdGQn45YMXSv2SaKUIWCS-zUdzeoelnaglNY7eDOLAZLVKSnk9fqZwuMBYeylg-Y6ZRXsggwCfx3Y6IIV4WN5qpQ2pFvvqaF5TA3flw50Iv_Cjq9M1C5ATCoIEXJTlzIyCHwwcGD_jMGrqTp98.1ZOl6nbyyJHKMmFqDR756g \ No newline at end of file diff --git a/android/src/test/res/nfc/expectApdu.yml b/common/src/commonTest/resources/nfc/expectApdu.yml similarity index 100% rename from android/src/test/res/nfc/expectApdu.yml rename to common/src/commonTest/resources/nfc/expectApdu.yml diff --git a/android/src/test/res/nfc/testParameters.yml b/common/src/commonTest/resources/nfc/testParameters.yml similarity index 100% rename from android/src/test/res/nfc/testParameters.yml rename to common/src/commonTest/resources/nfc/testParameters.yml diff --git a/common/src/commonTest/resources/pharmacy_parser_bundle.json b/common/src/commonTest/resources/pharmacy_parser_bundle.json new file mode 100644 index 00000000..76d155f0 --- /dev/null +++ b/common/src/commonTest/resources/pharmacy_parser_bundle.json @@ -0,0 +1,651 @@ +{ + "id": "5c605b2b-7dda-4bd7-b98f-57c8ae4fd180", + "type": "collection", + "timestamp": "2022-01-25T11:17:21.294+00:00", + "resourceType": "Bundle", + "link": [ + { + "relation": "self", + "url": "https://erp-ref.zentral.erp.splitdns.ti-dienste.de/Task/160.000.088.357.676.93" + } + ], + "entry": [ + { + "fullUrl": "https://erp-ref.zentral.erp.splitdns.ti-dienste.de/Task/160.000.088.357.676.93", + "resource": { + "resourceType": "Task", + "id": "160.000.088.357.676.93", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.088.357.676.93" + }, + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/AccessCode", + "value": "68db761b666f7e75a32090fd4d109e2766e02693741278ab6dc2df90f1cbb3af" + } + ], + "intent": "order", + "status": "ready", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-03-02" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2021-12-28" + } + ], + "authoredOn": "2021-11-30T14:16:43.239+00:00", + "lastModified": "2021-11-30T14:17:39.222+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "input": [ + { + "type": { + "coding": [ + { + "system": "https://gematik.de/fhir/CodeSystem/Documenttype", + "code": "1" + } + ] + }, + "valueReference": { + "reference": "a02c3b44-0500-0000-0001-000000000000" + } + }, + { + "type": { + "coding": [ + { + "system": "https://gematik.de/fhir/CodeSystem/Documenttype", + "code": "2" + } + ] + }, + "valueReference": { + "reference": "a02c3b44-0500-0000-0002-000000000000" + } + } + ], + "for": { + "identifier": { + "value": "X110498793", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + } + } + }, + { + "fullUrl": "urn:uuid:a02c3b44-0500-0000-0002-000000000000", + "resource": { + "resourceType": "Bundle", + "id": "a02c3b44-0500-0000-0002-000000000000", + "meta": { + "lastUpdated": "2021-09-13T18:00:40+00:00", + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Bundle|1.0.2" + ] + }, + "identifier": { + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.088.357.676.93" + }, + "type": "document", + "timestamp": "2021-11-30T15:16:43+00:00", + "entry": [ + { + "fullUrl": "http://testkrankenhaus.local/fhir/Composition/8cbabb0a-3253-4920-bec5-90359af6d157", + "resource": { + "resourceType": "Composition", + "id": "8cbabb0a-3253-4920-bec5-90359af6d157", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Composition|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_FOR_Legal_basis", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_STATUSKENNZEICHEN", + "code": "00" + } + } + ], + "status": "final", + "type": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_FORMULAR_ART", + "code": "e16A" + } + ] + }, + "subject": { + "reference": "Patient/b390fcbb-de50-4ffa-b06c-85523e036300" + }, + "date": "2021-11-30T15:16:32.534289+01:00", + "author": [ + { + "reference": "Practitioner/0c70b91c-b08f-49ae-840a-1522facb47a2", + "type": "Practitioner" + }, + { + "type": "Device", + "identifier": { + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_FOR_Pruefnummer", + "value": "Y/400/2012/01/777" + } + } + ], + "title": "elektronische Arzneimittelverordnung", + "custodian": { + "reference": "Organization/83a776a8-0983-4a04-ac1b-d530297b1d69" + }, + "section": [ + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Section_Type", + "code": "Prescription" + } + ] + }, + "entry": [ + { + "reference": "MedicationRequest/9f07d01c-85aa-4b96-a377-df3fc3130efb" + } + ] + }, + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Section_Type", + "code": "Coverage" + } + ] + }, + "entry": [ + { + "reference": "Coverage/40d36758-638e-43cd-b8a4-8d2d5b6863cb" + } + ] + } + ] + } + }, + { + "fullUrl": "http://testkrankenhaus.local/fhir/MedicationRequest/9f07d01c-85aa-4b96-a377-df3fc3130efb", + "resource": { + "resourceType": "MedicationRequest", + "id": "9f07d01c-85aa-4b96-a377-df3fc3130efb", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Prescription|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_StatusCoPayment", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_StatusCoPayment", + "code": "0" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_EmergencyServicesFee", + "valueBoolean": false + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_BVG", + "valueBoolean": false + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Multiple_Prescription", + "extension": [ + { + "url": "Kennzeichen", + "valueBoolean": false + } + ] + } + ], + "status": "active", + "intent": "order", + "medicationReference": { + "reference": "Medication/f0f9f06c-3864-444d-a642-dc6b9adb39fb" + }, + "subject": { + "reference": "Patient/b390fcbb-de50-4ffa-b06c-85523e036300" + }, + "authoredOn": "2021-11-30", + "requester": { + "reference": "Practitioner/0c70b91c-b08f-49ae-840a-1522facb47a2" + }, + "insurance": [ + { + "reference": "Coverage/40d36758-638e-43cd-b8a4-8d2d5b6863cb" + } + ], + "note": [ + { + "text": "Patient erneut auf Anwendung der Schmelztabletten hinweisen" + } + ], + "dosageInstruction": [ + { + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_DosageFlag", + "valueBoolean": true + } + ], + "text": "1x täglich" + } + ], + "dispenseRequest": { + "quantity": { + "value": 1, + "system": "http://unitsofmeasure.org", + "code": "{Package}" + } + }, + "substitution": { + "allowedBoolean": false + } + } + }, + { + "fullUrl": "http://testkrankenhaus.local/fhir/Medication/f0f9f06c-3864-444d-a642-dc6b9adb39fb", + "resource": { + "resourceType": "Medication", + "id": "f0f9f06c-3864-444d-a642-dc6b9adb39fb", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN|1.0.2" + ] + }, + "extension": [ + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_ERP_Medication_Category", + "code": "00" + } + }, + { + "url": "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine", + "valueBoolean": false + }, + { + "url": "http://fhir.de/StructureDefinition/normgroesse", + "valueCode": "N3" + } + ], + "code": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/ifa/pzn", + "code": "08850519" + } + ], + "text": "Olanzapin Heumann 20mg" + }, + "form": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM", + "code": "SMT" + } + ] + }, + "amount": { + "numerator": { + "value": 70, + "unit": "St" + }, + "denominator": { + "value": 1 + } + } + } + }, + { + "fullUrl": "http://testkrankenhaus.local/fhir/Patient/b390fcbb-de50-4ffa-b06c-85523e036300", + "resource": { + "resourceType": "Patient", + "id": "b390fcbb-de50-4ffa-b06c-85523e036300", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient|1.0.3" + ] + }, + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/identifier-type-de-basis", + "code": "GKV" + } + ] + }, + "system": "http://fhir.de/NamingSystem/gkv/kvid-10", + "value": "X110498793" + } + ], + "name": [ + { + "use": "official", + "family": "Graf Freiherr von Schaumberg", + "_family": { + "extension": [ + { + "url": "http://fhir.de/StructureDefinition/humanname-namenszusatz", + "valueString": "Graf Freiherr" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", + "valueString": "von" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", + "valueString": "Schaumberg" + } + ] + }, + "given": [ + "Karl-Friederich" + ], + "prefix": [ + "Prof. Dr." + ], + "_prefix": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-EN-qualifier", + "valueCode": "AC" + } + ] + } + ] + } + ], + "birthDate": "1964-04-04", + "address": [ + { + "type": "both", + "line": [ + "Siegburger Str. 155" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Siegburger Str." + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "155" + } + ] + } + ], + "city": "Köln", + "postalCode": "51105", + "country": "D" + } + ] + } + }, + { + "fullUrl": "http://testkrankenhaus.local/fhir/Practitioner/0c70b91c-b08f-49ae-840a-1522facb47a2", + "resource": { + "resourceType": "Practitioner", + "id": "0c70b91c-b08f-49ae-840a-1522facb47a2", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Practitioner|1.0.3" + ] + }, + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "LANR" + } + ] + }, + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_ANR", + "value": "445588777" + } + ], + "name": [ + { + "use": "official", + "family": "Popówitsch", + "_family": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/humanname-own-name", + "valueString": "Popówitsch" + } + ] + }, + "given": [ + "Hannelore" + ], + "prefix": [ + "Prof. Dr." + ], + "_prefix": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-EN-qualifier", + "valueCode": "AC" + } + ] + } + ] + } + ], + "qualification": [ + { + "code": { + "coding": [ + { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_FOR_Qualification_Type", + "code": "00" + } + ] + } + }, + { + "code": { + "text": "Innere und Allgemeinmedizin (Hausarzt)" + } + } + ] + } + }, + { + "fullUrl": "http://testkrankenhaus.local/fhir/Organization/83a776a8-0983-4a04-ac1b-d530297b1d69", + "resource": { + "resourceType": "Organization", + "id": "83a776a8-0983-4a04-ac1b-d530297b1d69", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Organization|1.0.3" + ] + }, + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0203", + "code": "BSNR" + } + ] + }, + "system": "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR", + "value": "998877665" + } + ], + "name": "Universitätsklinik Campus Süd", + "telecom": [ + { + "system": "phone", + "value": "06841/7654321" + }, + { + "system": "fax", + "value": "06841/4433221" + }, + { + "system": "email", + "value": "unikliniksued@test.de" + } + ], + "address": [ + { + "type": "both", + "line": [ + "Kirrberger Str. 100", + "Campus Süd" + ], + "_line": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-streetName", + "valueString": "Kirrberger Str." + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-houseNumber", + "valueString": "100" + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/iso21090-ADXP-additionalLocator", + "valueString": "Campus Süd" + } + ] + } + ], + "city": "Homburg", + "postalCode": "66421", + "country": "D" + } + ] + } + }, + { + "fullUrl": "http://testkrankenhaus.local/fhir/Coverage/40d36758-638e-43cd-b8a4-8d2d5b6863cb", + "resource": { + "resourceType": "Coverage", + "id": "40d36758-638e-43cd-b8a4-8d2d5b6863cb", + "meta": { + "profile": [ + "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Coverage|1.0.3" + ] + }, + "extension": [ + { + "url": "http://fhir.de/StructureDefinition/gkv/besondere-personengruppe", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_PERSONENGRUPPE", + "code": "00" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/dmp-kennzeichen", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DMP", + "code": "00" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/wop", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_ITA_WOP", + "code": "38" + } + }, + { + "url": "http://fhir.de/StructureDefinition/gkv/versichertenart", + "valueCoding": { + "system": "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_VERSICHERTENSTATUS", + "code": "1" + } + } + ], + "status": "active", + "type": { + "coding": [ + { + "system": "http://fhir.de/CodeSystem/versicherungsart-de-basis", + "code": "GKV" + } + ] + }, + "beneficiary": { + "reference": "Patient/b390fcbb-de50-4ffa-b06c-85523e036300" + }, + "payor": [ + { + "identifier": { + "use": "official", + "system": "http://fhir.de/NamingSystem/arge-ik/iknr", + "value": "109519005" + }, + "display": "AOK Nordost - Die Gesundheitskasse" + } + ] + } + } + ] + } + } + ] +} diff --git a/common/src/commonTest/resources/pharmacy_result_bundle.json b/common/src/commonTest/resources/pharmacy_result_bundle.json new file mode 100644 index 00000000..1a2dd765 --- /dev/null +++ b/common/src/commonTest/resources/pharmacy_result_bundle.json @@ -0,0 +1,890 @@ +{ + "id": "4ee57802-8102-493a-bc85-a6be03da1731", + "resourceType": "Bundle", + "type": "searchset", + "meta": { + "lastUpdated": "2021-04-17T03:55:31.554+00:00" + }, + "total": 10, + "link": [ + { + "relation": "self", + "url": "http://aro-apovzd-int.ngdalabor.de/hl7api/Location?name=apo" + } + ], + "entry": [ + { + "resource": { + "id": "4b74c2b2-2275-4153-a94d-3ddc6bfb1362", + "resourceType": "Location", + "contained": [ + { + "resourceType": "HealthcareService", + "id": "4b74c2b2-2275-4153-a94d-3ddc6bfb1362Mobile", + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-type", + "code": "498", + "display": "Mobile Services" + } + ] + } + ], + "location": [ + { + "reference": "Location/4b74c2b2-2275-4153-a94d-3ddc6bfb1362" + } + ], + "coverageArea": { + "extension": [ + { + "url": "https://ngda.de/fhir/extensions/ServiceCoverageRange", + "valueQuantity": { + "value": 12, + "unit": "km" + } + } + ] + }, + "availableTime": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "availableStartTime": "08:00:00", + "availableEndTime": "20:00:00" + } + ] + }, + { + "resourceType": "HealthcareService", + "id": "4b74c2b2-2275-4153-a94d-3ddc6bfb1362Emergency", + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/service-type", + "code": "117", + "display": "Emergency Medical" + } + ] + } + ], + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/resource-effectivePeriod", + "valuePeriod": { + "start": "2021-04-17", + "end": "2021-04-18" + } + } + ], + "location": [ + { + "reference": "Location/4b74c2b2-2275-4153-a94d-3ddc6bfb1362" + } + ], + "availableTime": [ + { + "daysOfWeek": [ + "sun" + ], + "allDay": true + }, + { + "daysOfWeek": [ + "sat" + ], + "availableStartTime": "18:00:00" + } + ] + } + ], + "address": { + "city": "Bremerhaven", + "country": "de", + "line": [ + "Langener Landstraße 266" + ], + "postalCode": "27578", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "14:00:00", + "closingTime": "18:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-05.2.1007600000.080" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1043553" + } + ], + "status": "active", + "name": "Heide-Apotheke", + "position": { + "latitude": 8.597412, + "longitude": 53.590027 + }, + "telecom": [ + { + "system": "phone", + "value": "0471/87029" + }, + { + "system": "fax", + "value": "0471/87020" + }, + { + "system": "email", + "value": "info@heide-apotheke-bremerhaven.de" + }, + { + "system": "url", + "value": "http://www.heide-apotheke-bremerhaven.de" + } + ], + "type": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "PHARM", + "display": "pharmacy" + } + ] + }, + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "OUTPHARM", + "display": "outpatient pharmacy" + } + ] + }, + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "MOBL", + "display": "Mobile Services" + } + ] + } + ] + } + }, + { + "resource": { + "id": "db6e2b84-c948-4e3c-ab8d-b35e9f88e6a5", + "resourceType": "Location", + "address": { + "city": "Bad Lippspringe", + "country": "de", + "line": [ + "Detmolder Straße 139" + ], + "postalCode": "33175", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-17.2.1013006000.448" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1087501" + } + ], + "status": "active", + "name": "Kur-Apotheke", + "position": { + "latitude": 8.816655, + "longitude": 51.781866 + }, + "telecom": [ + { + "system": "phone", + "value": "+495252931818" + }, + { + "system": "fax", + "value": "+495252931828" + }, + { + "system": "email", + "value": "kurapotheke@gmx.de" + }, + { + "system": "url", + "value": "https://www.kurapotheke-badlippspringe.de" + } + ] + } + }, + { + "resource": { + "id": "000b95a9-617a-4721-8213-c7d6a0aaf1a4", + "resourceType": "Location", + "address": { + "city": "Duisburg", + "country": "de", + "line": [ + "Bahnhofstr. 24" + ], + "postalCode": "47138", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-10.2.0110201000.579" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1060500" + } + ], + "status": "active", + "name": "Anker Apotheke", + "position": { + "latitude": 6.786679, + "longitude": 51.472874 + }, + "telecom": [ + { + "system": "phone", + "value": "0203/425150" + }, + { + "system": "fax", + "value": "0203/412930" + }, + { + "system": "email", + "value": "anker-apotheke-duisburg@t-online.de" + }, + { + "system": "url", + "value": "https://www.anker-apotheke-meiderich.de" + } + ] + } + }, + { + "resource": { + "id": "4c8d59fe-3872-4ec4-bc84-bb0dc8de8c1f", + "resourceType": "Location", + "address": { + "city": "Immenstadt", + "country": "de", + "line": [ + "Bahnhofstraße 36" + ], + "postalCode": "87509", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-02.2.0000002545.10.327" + } + ], + "status": "active", + "name": "Alpen Apotheke", + "position": { + "latitude": 10.213959, + "longitude": 47.559614 + }, + "telecom": [ + { + "system": "phone", + "value": "+4983232677" + }, + { + "system": "fax", + "value": "+4983237451" + }, + { + "system": "email", + "value": "service@alpen-apotheke.com" + }, + { + "system": "url", + "value": "http://www.alpen-apotheke.com" + } + ] + } + }, + { + "resource": { + "id": "43f8fa6e-31bc-4d4e-9a58-0ceaff8a0334", + "resourceType": "Location", + "address": { + "city": "Delmenhorst", + "country": "de", + "line": [ + "Brendelweg 5" + ], + "postalCode": "27755", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-09.2.1673000000.10.504" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1054235" + } + ], + "status": "active", + "name": "Moorkamp-Apotheke", + "position": { + "latitude": 8.623093, + "longitude": 53.032777 + }, + "telecom": [ + { + "system": "phone", + "value": "04221-25055" + }, + { + "system": "fax", + "value": "04221-25056" + }, + { + "system": "email", + "value": "moorkamp-apotheke@web.de" + }, + { + "system": "url", + "value": "https://www.moorkamp-apotheke-delmenhorst.de/" + } + ] + } + }, + { + "resource": { + "id": "fd56e85c-ce78-41fa-bb79-9deab616fc55", + "resourceType": "Location", + "address": { + "city": "München", + "country": "de", + "line": [ + "Daiserstraße 27" + ], + "postalCode": "81371", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-02.2.0000000261.815" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1164711" + } + ], + "status": "active", + "name": "Oberländer Apotheke", + "position": { + "latitude": 11.54399, + "longitude": 48.11939 + }, + "telecom": [ + { + "system": "phone", + "value": "089/763756" + }, + { + "system": "fax", + "value": "089/" + }, + { + "system": "email", + "value": "info@oberlaender-apotheke.de" + }, + { + "system": "url", + "value": "https://www.oberländer-apotheke.de/" + } + ] + } + }, + { + "resource": { + "id": "7a20ebb1-d2cd-4fac-b66a-ce1524ad57b3", + "resourceType": "Location", + "address": { + "city": "Eichenbarleben", + "country": "de", + "line": [ + "Magdeburger Straße 57" + ], + "postalCode": "39166", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-13.2.0000000168.253" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1167838" + } + ], + "status": "active", + "name": "Hirsch-Apotheke", + "position": { + "latitude": 11.40572, + "longitude": 52.168397 + }, + "telecom": [ + { + "system": "phone", + "value": "03920650307" + }, + { + "system": "fax", + "value": "03920690266" + }, + { + "system": "email", + "value": "hirsch.eichenbarleben@t-online.de" + }, + { + "system": "url" + } + ] + } + }, + { + "resource": { + "id": "1b2e9fe0-5916-4069-818c-1e7af5d1a7ea", + "resourceType": "Location", + "address": { + "city": "Grebenhain", + "country": "de", + "line": [ + "Hauptstraße 37" + ], + "postalCode": "36355", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-07.2.2668290000.098" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1000064" + } + ], + "status": "active", + "name": "Apotheke in Grebenhain", + "position": { + "latitude": 9.333697, + "longitude": 50.488624 + }, + "telecom": [ + { + "system": "phone", + "value": "06644/286" + }, + { + "system": "fax", + "value": "06644/919177" + }, + { + "system": "email", + "value": "info@apotheke-grebenhain.de" + }, + { + "system": "url", + "value": "https://apotheke-grebenhain.de" + } + ] + } + }, + { + "resource": { + "id": "9e96a62a-004b-41da-849c-6718c8af8906", + "resourceType": "Location", + "address": { + "city": "Cloppenburg", + "country": "de", + "line": [ + "Krankenhausstraße 8-12" + ], + "postalCode": "49661", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-09.2.6932000000.10.990" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1043443" + } + ], + "status": "active", + "name": "Apotheke Meis am Krankenhaus", + "position": { + "latitude": 8.039899, + "longitude": 52.847762 + }, + "telecom": [ + { + "system": "phone", + "value": "044718889925" + }, + { + "system": "fax", + "value": "044718889926" + }, + { + "system": "email", + "value": "info@apo-meis.de" + }, + { + "system": "url", + "value": "http://www.apo-meis.de" + } + ] + } + }, + { + "resource": { + "id": "ccec2e5b-8e9f-459c-abfa-1f495d381c8c", + "resourceType": "Location", + "address": { + "city": "Olching", + "country": "de", + "line": [ + "Hauptstraße 30" + ], + "postalCode": "82140", + "text": "", + "type": "physical", + "use": "work" + }, + "hoursOfOperation": [ + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + }, + { + "daysOfWeek": [ + "mon", + "tue", + "wed", + "thu", + "fri" + ], + "openingTime": "08:00:00", + "closingTime": "12:00:00" + } + ], + "identifier": [ + { + "system": "https://gematik.de/fhir/NamingSystem/TelematikID", + "value": "3-02.2.0000000789.290" + }, + { + "system": "https://ngda.de/fhir/NamingSystem/NID", + "value": "APO1021283" + } + ], + "status": "active", + "name": "Rosen-Apotheke Olching", + "position": { + "latitude": 11.328714, + "longitude": 48.208105 + }, + "telecom": [ + { + "system": "phone", + "value": "0814215042" + }, + { + "system": "fax", + "value": "0814213453" + }, + { + "system": "email", + "value": "mail@rosen-apotheke-olching.de" + }, + { + "system": "url", + "value": "https://www.rosen-apotheke-olching.de" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/common/src/commonTest/resources/task_bundle.json b/common/src/commonTest/resources/task_bundle.json new file mode 100644 index 00000000..c4086b05 --- /dev/null +++ b/common/src/commonTest/resources/task_bundle.json @@ -0,0 +1,325 @@ +{ + "id": "ba277f04-724b-4baf-a4bf-a44d9b7035c0", + "type": "searchset", + "timestamp": "2022-09-01T09:36:17.636+00:00", + "resourceType": "Bundle", + "total": 5, + "link": [ + { + "relation": "self", + "url": "https://erp-test.zentral.erp.splitdns.ti-dienste.de/Task" + } + ], + "entry": [ + { + "fullUrl": "https://erp-test.zentral.erp.splitdns.ti-dienste.de/Task/160.000.000.036.915.86", + "resource": { + "resourceType": "Task", + "id": "160.000.000.036.915.86", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.036.915.86" + } + ], + "intent": "order", + "status": "ready", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-12-01" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2022-09-28" + } + ], + "authoredOn": "2022-08-31T15:03:58.827+00:00", + "lastModified": "2022-08-31T15:03:59.549+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "for": { + "identifier": { + "value": "X110535541", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + } + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://erp-test.zentral.erp.splitdns.ti-dienste.de/Task/160.000.000.031.686.59", + "resource": { + "resourceType": "Task", + "id": "160.000.000.031.686.59", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.031.686.59" + } + ], + "intent": "order", + "status": "completed", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-10-12" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2022-08-09" + } + ], + "authoredOn": "2022-07-12T11:06:25.744+00:00", + "lastModified": "2022-07-12T11:08:10.946+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "for": { + "identifier": { + "value": "X110535541", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + } + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://erp-test.zentral.erp.splitdns.ti-dienste.de/Task/160.000.000.026.902.55", + "resource": { + "resourceType": "Task", + "id": "160.000.000.026.902.55", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.026.902.55" + } + ], + "intent": "order", + "status": "in-progress", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-05-16" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2022-03-16" + } + ], + "authoredOn": "2022-02-16T13:52:22.561+00:00", + "lastModified": "2022-02-16T13:52:37.724+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "for": { + "identifier": { + "value": "X110535541", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + } + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://erp-test.zentral.erp.splitdns.ti-dienste.de/Task/160.000.000.031.424.69", + "resource": { + "resourceType": "Task", + "id": "160.000.000.031.424.69", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.031.424.69" + } + ], + "intent": "order", + "status": "completed", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-10-08" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2022-08-05" + } + ], + "authoredOn": "2022-07-08T10:07:24.680+00:00", + "lastModified": "2022-07-08T10:09:33.113+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "for": { + "identifier": { + "value": "X110535541", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + } + }, + "search": { + "mode": "match" + } + }, + { + "fullUrl": "https://erp-test.zentral.erp.splitdns.ti-dienste.de/Task/160.000.000.035.369.68", + "resource": { + "resourceType": "Task", + "id": "160.000.000.035.369.68", + "meta": { + "profile": [ + "https://gematik.de/fhir/StructureDefinition/ErxTask|1.1.1" + ] + }, + "identifier": [ + { + "use": "official", + "system": "https://gematik.de/fhir/NamingSystem/PrescriptionID", + "value": "160.000.000.035.369.68" + } + ], + "intent": "order", + "status": "ready", + "extension": [ + { + "url": "https://gematik.de/fhir/StructureDefinition/PrescriptionType", + "valueCoding": { + "system": "https://gematik.de/fhir/CodeSystem/Flowtype", + "code": "160", + "display": "Muster 16 (Apothekenpflichtige Arzneimittel)" + } + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/ExpiryDate", + "valueDate": "2022-10-21" + }, + { + "url": "https://gematik.de/fhir/StructureDefinition/AcceptDate", + "valueDate": "2022-08-18" + } + ], + "authoredOn": "2022-07-21T07:58:12.730+00:00", + "lastModified": "2022-07-21T07:58:19.376+00:00", + "performerType": [ + { + "coding": [ + { + "system": "urn:ietf:rfc:3986", + "code": "urn:oid:1.2.276.0.76.4.54", + "display": "Öffentliche Apotheke" + } + ], + "text": "Öffentliche Apotheke" + } + ], + "for": { + "identifier": { + "value": "X110535541", + "system": "http://fhir.de/NamingSystem/gkv/kvid-10" + } + } + }, + "search": { + "mode": "match" + } + } + ] +} diff --git a/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt new file mode 100644 index 00000000..1a450b99 --- /dev/null +++ b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/SecureRandomProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app + +import java.security.SecureRandom + +actual fun secureRandomInstance(): SecureRandom = SecureRandom() diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardKeyReference.kt b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt similarity index 65% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardKeyReference.kt rename to common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt index 22282f2a..97f57503 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardKeyReference.kt +++ b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt @@ -16,19 +16,14 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.idp.usecase -/** - * interface that identifier: - * - * - symmetric authentication object, - * - symmetric map connection object, - * - or private key object - */ -interface ICardKeyReference { - fun calculateKeyReference(dfSpecific: Boolean): Int +import java.security.KeyStore +import java.security.Signature - companion object { - const val DF_SPECIFIC_PWD_MARKER = 0x80 - } +actual class IdpCryptoProvider { + actual fun keyStoreInstance(): KeyStore = + KeyStore.getInstance(KeyStore.getDefaultType()) + actual fun signatureInstance(algorithm: String): Signature = + Signature.getInstance(algorithm, KeyStore.getDefaultType()) } diff --git a/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt new file mode 100644 index 00000000..214fb528 --- /dev/null +++ b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +actual class IdpDeviceInfoProvider { + actual val deviceName: String = "" + actual val manufacturer: String = "" + actual val productName: String = "" + actual val model: String = "" + actual val operatingSystem: String = "" + actual val operatingSystemVersion: String = "" +} diff --git a/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt new file mode 100644 index 00000000..b14860ec --- /dev/null +++ b/common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpPreferenceProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + * + */ + +package de.gematik.ti.erp.app.idp.usecase + +actual class IdpPreferenceProvider { + actual var externalAuthenticationPreferences: ExternalAuthenticationPreferences + get() = ExternalAuthenticationPreferences() + set(_) {} +} diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml new file mode 100644 index 00000000..83e909af --- /dev/null +++ b/config/detekt/baseline.xml @@ -0,0 +1,513 @@ + + + + + ClassNaming:SchemaTest.kt$RealmA_V1 : RealmObject + ClassNaming:SchemaTest.kt$RealmA_V2 : RealmObject + ClassNaming:SchemaTest.kt$RealmA_V3 : RealmObject + ClassNaming:SchemaTest.kt$RealmB_V1 : RealmObject + ComplexCondition:EditShippingContactScreen.kt$!telephoneError && !mailError && !nameError && !line1Error && !codeAndCityError + ComplexCondition:LoginWithHealthCardScreen.kt$(can.length == it || it == 5 && can.length == 6) && isFocussed + ComplexCondition:LoginWithHealthCardScreen.kt$triggerAuth && state.firstVisibleItemIndex == 4 && cardAccessNumber.isNotBlank() && personalIdentificationNumber.isNotBlank() + ComplexCondition:Workarounds.kt$Workarounds$osName == "Mac OS X" && majorJavaVersion <= 16 && (majorOsVersion == 11 || majorOsVersion == 12) + ComplexMethod:SyncedTaskEntityV1Test.kt$SyncedTaskEntityV1Test$@Test fun `cascading delete`() + EmptyDefaultConstructor:PrefixedLogger.kt$NapierLogger$() + EmptyFunctionBlock:DebugScreenWrapper.kt${} + EmptyFunctionBlock:Main.kt$LogHandler${ } + EmptyFunctionBlock:VideoContent.kt$<no name provided>${} + FunctionParameterNaming:IdpService.kt$IdpService$@Query("redirect_uri") redirect_uri: String = REDIRECT_URI + ImplicitDefaultLocale:ApplicationIdentifier.kt$ApplicationIdentifier$String.format( "Application File Identifier length out of valid range [%d,%d]", AID_MIN_LENGTH, AID_MAX_LENGTH ) + ImplicitDefaultLocale:CardKey.kt$CardKey$String.format( "Key ID out of range [%d,%d]", MIN_KEY_ID, MAX_KEY_ID ) + ImplicitDefaultLocale:ShortFileIdentifier.kt$ShortFileIdentifier$String.format( "Short File Identifier out of valid range [%d,%d]", MIN_VALUE, MAX_VALUE ) + LargeClass:StringResource.kt$Strings + LongMethod:AndroidStringResourceGeneratorTask.kt$AndroidStringResourceGeneratorTask$@OptIn(ExperimentalXmlUtilApi::class, ExperimentalStdlibApi::class) @TaskAction fun generateStringResources() + LongMethod:CardWallComponents.kt$@Composable fun CardWallScreen( mainNavController: NavController, onResumeCardWall: () -> Unit, profileId: ProfileIdentifier ) + LongMethod:KBVCodeMapping.kt$fun Strings.codeToDosageFormMapping() + LongMethod:LoginWithHealthCardScreen.kt$@OptIn( ExperimentalMaterialApi::class, ExperimentalAnimationApi::class ) @Composable fun LoginWithHealthCard( viewModel: LoginWithHealthCardViewModel, onFinished: () -> Unit, onClose: () -> Unit ) + LongMethod:Main.kt$fun main() + LongMethod:RedeemComponents.kt$@OptIn(ExperimentalMaterialApi::class) @Composable fun RedeemScreen(taskIds: List<String>, navController: NavController) + LongMethod:SyncedTaskEntityV1Test.kt$SyncedTaskEntityV1Test$@Test fun `cascading delete`() + LongParameterList:HealthCardVersion2.kt$HealthCardVersion2$( /** * Information of C0 with version of filling instruction for version2 */ val fillingInstructionsVersion: ByteArray, // C0 /** * Information of C1 with version of card object system */ val objectSystemVersion: ByteArray, // C1 /** * Information of C2 with version of product identification object system */ val productIdentificationObjectSystemVersion: ByteArray, // C2 /** * Information of C4 with version of filling instruction for EF.GDO */ val fillingInstructionsEfGdoVersion: ByteArray, // C4 /** * Information of C5 with version of filling instruction for EF.ATR */ val fillingInstructionsEfAtrVersion: ByteArray, // C5 /** * Information of C6 with version of filling instruction for EF.KeyInfo * Only filled for gSMC-K and gSMC-KT */ val fillingInstructionsEfKeyInfoVersion: ByteArray, // C6 //only gSMC-K and gSMC-KT /** * Information of C3 with version of filling instruction for Environment Settings * Only filled for gSMC-K */ val fillingInstructionsEfEnvironmentSettingsVersion: ByteArray, // C3 //only gSMC-K /** * Information of C7 with version of filling instruction for EF.GDO */ val fillingInstructionsEfLoggingVersion: ByteArray // C7 ) + LongParameterList:IdpUseCase.kt$IdpUseCase$( private val repository: IdpRepository, private val pairingRepository: IdpPairingRepository, private val altAuthUseCase: IdpAlternateAuthenticationUseCase, private val profilesRepository: ProfilesRepository, private val basicUseCase: IdpBasicUseCase, private val preferences: IdpPreferenceProvider, private val cryptoProvider: IdpCryptoProvider ) + MagicNumber:Animations.kt$1 + MagicNumber:Animations.kt$3 + MagicNumber:Apdu.kt$0xFF + MagicNumber:Apdu.kt$8 + MagicNumber:Apdu.kt$CommandApdu.Companion$0xFF + MagicNumber:Apdu.kt$CommandApdu.Companion$255 + MagicNumber:Apdu.kt$CommandApdu.Companion$5 + MagicNumber:Apdu.kt$CommandApdu.Companion$65535 + MagicNumber:Apdu.kt$CommandApdu.Companion$7 + MagicNumber:AuthenticationUseCase.kt$AuthenticationUseCase$1000 + MagicNumber:BasicData.kt$3 + MagicNumber:BasicData.kt$4 + MagicNumber:BasicData.kt$IdpNonce.Companion$32 + MagicNumber:CardWallAuthDialog.kt$0.3f + MagicNumber:CardWallAuthDialog.kt$0.7f + MagicNumber:CardWallAuthDialog.kt$1.1f + MagicNumber:CardWallAuthDialog.kt$10 + MagicNumber:CardWallAuthDialog.kt$1000 + MagicNumber:CardWallAuthDialog.kt$1300 + MagicNumber:CardWallAuthDialog.kt$1500 + MagicNumber:CardWallAuthDialog.kt$2500 + MagicNumber:CardWallAuthDialog.kt$300 + MagicNumber:CardWallAuthDialog.kt$3000 + MagicNumber:CardWallAuthDialog.kt$5000 + MagicNumber:CardWallAuthDialog.kt$600 + MagicNumber:CardWallComponents.kt$6 + MagicNumber:CardWallComponents.kt$8 + MagicNumber:CardWallNfcInstructionScreen.kt$1.5f + MagicNumber:CardWallNfcInstructionScreen.kt$3 + MagicNumber:CardWallNfcInstructionScreen.kt$6 + MagicNumber:CertUtils.kt$3 + MagicNumber:ClientCrypto.kt$3 + MagicNumber:ClientCrypto.kt$VauChannelSpec$3 + MagicNumber:ClientCrypto.kt$VauChannelSpec$4 + MagicNumber:ClientCrypto.kt$VauChannelSpec$5 + MagicNumber:ClientCrypto.kt$VauChannelSpec$8 + MagicNumber:Common.kt$1 + MagicNumber:Crypto.kt$AesGcm$8 + MagicNumber:Crypto.kt$Ecies$16 + MagicNumber:Crypto.kt$Ecies$32 + MagicNumber:CryptoUtils.kt$256 + MagicNumber:DebugSettingsViewModel.kt$DebugSettingsViewModel$48 + MagicNumber:DebugSettingsViewModel.kt$DebugSettingsViewModel$64 + MagicNumber:Dialog.kt$0.78f + MagicNumber:EcdsaUsingShaAlgorithmExtending.kt$EcdsaUsingShaAlgorithmExtending.EcdsaBP256R1UsingSha256$64 + MagicNumber:EcdsaUsingShaAlgorithmExtending.kt$EcdsaUsingShaAlgorithmExtending.EcdsaBP384R1UsingSha384$64 + MagicNumber:EcdsaUsingShaAlgorithmExtending.kt$EcdsaUsingShaAlgorithmExtending.EcdsaBP512R1UsingSha512$64 + MagicNumber:EditShippingContactScreen.kt$4 + MagicNumber:EditShippingContactScreen.kt$5 + MagicNumber:EditShippingContactScreen.kt$6 + MagicNumber:EditShippingContactScreen.kt$7 + MagicNumber:EditShippingContactScreen.kt$8 + MagicNumber:FileIdentifier.kt$FileIdentifier$0x011C + MagicNumber:FileIdentifier.kt$FileIdentifier$0x1000 + MagicNumber:FileIdentifier.kt$FileIdentifier$0x3FFF + MagicNumber:FileIdentifier.kt$FileIdentifier$0xFEFF + MagicNumber:GeneralAuthenticateCommand.kt$28 + MagicNumber:HealthCardOrderComponents.kt$9 + MagicNumber:HealthCardVersion2.kt$16 + MagicNumber:HealthCardVersion2.kt$8 + MagicNumber:HealthCardVersion2.kt$HealthCardVersion2.Companion$3 + MagicNumber:HealthCardVersion2.kt$HealthCardVersion2.Companion$4 + MagicNumber:HealthCardVersion2.kt$HealthCardVersion2.Companion$5 + MagicNumber:HealthCardVersion2.kt$HealthCardVersion2.Companion$6 + MagicNumber:HealthCardVersion2.kt$HealthCardVersion2.Companion$7 + MagicNumber:Hints.kt$1 + MagicNumber:Hints.kt$2000 + MagicNumber:IdpAlternateAuthenticationUseCase.kt$IdpAlternateAuthenticationUseCase$1000 + MagicNumber:IdpAlternateAuthenticationUseCase.kt$IdpAlternateAuthenticationUseCase$32 + MagicNumber:IdpBasicUseCase.kt$3 + MagicNumber:IdpBasicUseCase.kt$4 + MagicNumber:IdpBasicUseCase.kt$IdpBasicUseCase$60 + MagicNumber:IdpBasicUseCase.kt$IdpNonce.Companion$32 + MagicNumber:IdpData.kt$24 + MagicNumber:IdpUseCase.kt$IdpUseCase$400 + MagicNumber:IdpUseCase.kt$IdpUseCase$401 + MagicNumber:IdpUseCase.kt$IdpUseCase$403 + MagicNumber:JWTExtensions.kt$64 + MagicNumber:LocalDataSource.kt$AuditEventLocalDataSource$3 + MagicNumber:LoginWithHealthCardScreen.kt$1000 + MagicNumber:LoginWithHealthCardScreen.kt$3 + MagicNumber:LoginWithHealthCardScreen.kt$4 + MagicNumber:LoginWithHealthCardScreen.kt$5 + MagicNumber:LoginWithHealthCardScreen.kt$6 + MagicNumber:LoginWithHealthCardViewModel.kt$LoginWithHealthCardViewModel$1000 + MagicNumber:LoginWithHealthCardViewModel.kt$LoginWithHealthCardViewModel$2000 + MagicNumber:Main.kt$0.1f + MagicNumber:Main.kt$1.5f + MagicNumber:MainComposable.kt$1f + MagicNumber:MainScreen.kt$0xff + MagicNumber:MainScreen.kt$4 + MagicNumber:ManageSecurityEnvironmentCommand.kt$3 + MagicNumber:ManageSecurityEnvironmentCommand.kt$4 + MagicNumber:OnboardingComponents.kt$9 + MagicNumber:OverlayPopup.kt$0.5f + MagicNumber:OverlayPopup.kt$0.7f + MagicNumber:OverlayPopup.kt$15f + MagicNumber:PairedDevices.kt$0.33f + MagicNumber:PasswordScreen.kt$0.05f + MagicNumber:PasswordScreen.kt$0.1f + MagicNumber:PasswordScreen.kt$0.3f + MagicNumber:PasswordScreen.kt$0.6f + MagicNumber:PasswordScreen.kt$3 + MagicNumber:PasswordScreen.kt$4 + MagicNumber:PharmacyOrderScreen.kt$0.33f + MagicNumber:PharmacySearchModel.kt$Location$180.0 + MagicNumber:PrescriptionScreenComponents.kt$20 + MagicNumber:PrescriptionScreenComponents.kt$21 + MagicNumber:PrescriptionScreenComponents.kt$5L + MagicNumber:PrescriptionScreenComponents.kt$60L + MagicNumber:PrescriptionScreenComponents.kt$97 + MagicNumber:PrescriptionViewModel.kt$PrescriptionViewModel$1000L + MagicNumber:PrescriptionViewModel.kt$PrescriptionViewModel$60L + MagicNumber:ProtocolUseCase.kt$ProtocolUseCase$50 + MagicNumber:QueryUtils.kt$100 + MagicNumber:RedeemComponents.kt$10 + MagicNumber:RedeemViewModel.kt$RedeemViewModel$3 + MagicNumber:RedeemViewModel.kt$RedeemViewModel$5 + MagicNumber:SafetynetUseCase.kt$SafetynetUseCase$12 + MagicNumber:SafetynetUseCase.kt$SafetynetUseCase$32 + MagicNumber:ScanPrescriptionsViewModel.kt$ScanPrescriptionViewModel$1000L + MagicNumber:ScanPrescriptionsViewModel.kt$ScanPrescriptionViewModel$3000L + MagicNumber:ScanScreenComponent.kt$0.4f + MagicNumber:ScanScreenComponent.kt$0.6f + MagicNumber:ScanScreenComponent.kt$1 + MagicNumber:ScanScreenComponent.kt$100 + MagicNumber:ScanScreenComponent.kt$1000 + MagicNumber:ScanScreenComponent.kt$100L + MagicNumber:ScanScreenComponent.kt$1024 + MagicNumber:ScanScreenComponent.kt$200 + MagicNumber:ScanScreenComponent.kt$3 + MagicNumber:ScanScreenComponent.kt$300 + MagicNumber:ScanScreenComponent.kt$300L + MagicNumber:ScanScreenComponent.kt$4 + MagicNumber:ScanScreenComponent.kt$5 + MagicNumber:ScanScreenComponent.kt$6 + MagicNumber:ScanScreenComponent.kt$7 + MagicNumber:ScanScreenComponent.kt$768 + MagicNumber:ScanScreenComponent.kt$8 + MagicNumber:ScanScreenComponent.kt$800 + MagicNumber:Schema.kt$1 + MagicNumber:SecureMessaging.kt$SecureMessaging$0xFF + MagicNumber:SecureMessaging.kt$SecureMessaging$1 + MagicNumber:SecureMessaging.kt$SecureMessaging$255 + MagicNumber:SecureMessaging.kt$SecureMessaging$3 + MagicNumber:SettingsData.kt$SettingsData.AuthenticationMode.Password$32 + MagicNumber:TextUtil.kt$0x1F600 + MagicNumber:TextUtil.kt$0x200d + MagicNumber:TextUtil.kt$0xE007F + MagicNumber:Translatable.kt$Plurals$11 + MagicNumber:Translatable.kt$Plurals$3 + MagicNumber:Translatable.kt$Plurals$4 + MagicNumber:Translatable.kt$Plurals$99 + MagicNumber:TrustedChannelPaceKeyExchange.kt$3 + MagicNumber:TrustedChannelPaceKeyExchange.kt$5 + MagicNumber:TruststoreUseCase.kt$TrustedTruststore.Companion$3 + MagicNumber:TwoDCodeProcessor.kt$126 + MagicNumber:TwoDCodeProcessor.kt$32 + MagicNumber:TwoDCodeProcessor.kt$TwoDCodeProcessor$270 + MagicNumber:TwoDCodeProcessor.kt$TwoDCodeProcessor$90 + MagicNumber:UnlockEgkDialog.kt$1000 + MagicNumber:UnlockEgkDialog.kt$5000 + MagicNumber:Utils.kt$0xFF + MagicNumber:Utils.kt$10 + MagicNumber:Utils.kt$15 + MagicNumber:Utils.kt$16 + MagicNumber:Utils.kt$48 + MagicNumber:Utils.kt$9 + MagicNumber:Utils.kt$97 + MagicNumber:VisibleDebugTree.kt$VisibleDebugTree$10 + MagicNumber:VisibleDebugTree.kt$VisibleDebugTree$500 + MagicNumber:WebViewScreen.kt$100 + MagicNumber:Workarounds.kt$Workarounds$11 + MagicNumber:Workarounds.kt$Workarounds$12 + MagicNumber:Workarounds.kt$Workarounds$16 + MandatoryBracesIfStatements:EllipticCurvesExtending.kt$EllipticCurvesExtending$try { addCurve("BP-256", BP256) addCurve("BP-384", BP384) addCurve("BP-512", BP512) AlgorithmFactoryFactory.getInstance().jwsAlgorithmFactory.registerAlgorithm( EcdsaBP256R1UsingSha256() ) AlgorithmFactoryFactory.getInstance().jwsAlgorithmFactory.registerAlgorithm( EcdsaBP384R1UsingSha384() ) AlgorithmFactoryFactory.getInstance().jwsAlgorithmFactory.registerAlgorithm( EcdsaBP512R1UsingSha512() ) initializedInSession = true true } catch (e: Exception) { throw IllegalStateException("failure on init $e") } + MandatoryBracesIfStatements:LoginWithHealthCardScreen.kt$5 + MandatoryBracesIfStatements:PharmacyOrderScreen.kt$Modifier + MaxLineLength:AndroidStringResourceGeneratorTask.kt$AndroidStringResourceGeneratorTask$println("WARNING: Additional `${locale.language}` keys not found in primary language `${primaryLocale.language}`") + MaxLineLength:Apdu.kt$CommandApdu.Companion$ne?.let { require(ne <= EXPECTED_LENGTH_WILDCARD_EXTENDED || ne >= 0) { "APDU response length is out of bounds [0, 65536]" } } + MaxLineLength:Apdu.kt$CommandApdu.Companion$require(!(cla > 0xFF || ins > 0xFF || p1 > 0xFF || p2 > 0xFF)) { "APDU header fields must not be greater than 255 (0xFF)" } + MaxLineLength:AppDependenciesPlugin.kt$AppDependenciesPlugin.Dependencies.Network$const val retrofit2KotlinXSerialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0" + MaxLineLength:AuthenticationUseCase.kt$AuthenticationUseCase$throw AuthenticationException(AuthenticationExceptionKind.IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate) + MaxLineLength:BottomSheetAction.kt$LocalContentColor provides if (titleColor == Color.Unspecified) LocalContentColor.current else titleColor + MaxLineLength:CardUtilitiesTest.kt$CardUtilitiesTest$"(4e2778f6aaef54cb42865a3c30c753495af4e53121400802d0ab1acd665e9c77,4c2fae1687e9daa36c64570c909f93176f01eeafcb45f9c08e49805f127d94ef,1,7d5a0975fc2c3057eef67530417affe7fb8055c126dc5c6ce94a4b44f330b5d9)" + MaxLineLength:CardUtilitiesTest.kt$CardUtilitiesTest$Hex.decode("041B05278F276BD92E6B0EE3478BD3A93B03FE8E4C35556F0D6C13C89C504F91C065E85C1D289B306F61BE2CECCED4E7532BF0925A4907F246DF7A69C8D69ED24F") + MaxLineLength:CardUtilitiesTest.kt$CardUtilitiesTest$Hex.decode("7C438341041B05278F276BD92E6B0EE3478BD3A93B03FE8E4C35556F0D6C13C89C504F91C065E85C1D289B306F61BE2CECCED4E7532BF0925A4907F246DF7A69C8D69ED24F") + MaxLineLength:CardUtilitiesTest.kt$CardUtilitiesTest$private val byteArray: ByteArray = Hex.decode("044E2778F6AAEF54CB42865A3C30C753495AF4E53121400802D0AB1ACD665E9C774C2FAE1687E9DAA36C64570C909F93176F01EEAFCB45F9C08E49805F127D94EF") + MaxLineLength:CardWallAuthDialog.kt$stringResource(R.string.cdw_nfc_error_body_invalid_ocsp_response_of_health_card_certificate).toAnnotatedString() + MaxLineLength:CardWallAuthDialog.kt$stringResource(R.string.cdw_nfc_error_title_invalid_ocsp_response_of_health_card_certificate).toAnnotatedString() + MaxLineLength:CardWallComponents.kt$if + MaxLineLength:CardWallComponents.kt$onClickAlternateAuthentication = { navController.navigate(CardWallNavigation.ExternalAuthenticator.path()) } + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos < LowerPos && nfcYPos < LowerPos -> stringResource(R.string.nfc_instruction_chip_location_top_left) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos < LowerPos && nfcYPos > HigherPos -> stringResource(R.string.nfc_instruction_chip_location_bot_left) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos < LowerPos && nfcYPos in PosRange -> stringResource(R.string.nfc_instruction_chip_location_middle_left) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos > HigherPos && nfcYPos < LowerPos -> stringResource(R.string.nfc_instruction_chip_location_top_right) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos > HigherPos && nfcYPos > HigherPos -> stringResource(R.string.nfc_instruction_chip_location_bot_right) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos > HigherPos && nfcYPos in PosRange -> stringResource(R.string.nfc_instruction_chip_location_middle_right) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos in PosRange && nfcYPos < LowerPos -> stringResource(R.string.nfc_instruction_chip_location_top_central) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos in PosRange && nfcYPos > HigherPos -> stringResource(R.string.nfc_instruction_chip_location_bot_central) + MaxLineLength:CardWallNfcInstructionScreen.kt$nfcXPos in PosRange && nfcYPos in PosRange -> stringResource(R.string.nfc_instruction_chip_location_middle) + MaxLineLength:CardWallNfcInstructionScreen.kt$x = ((phoneImgSize.width * -cos(nfcXPos * PI) / 6) + (phoneImgSize.width * cos(nfcYPos * PI) / 3)).toInt() + MaxLineLength:CardWallNfcInstructionScreen.kt$y = ((phoneImgSize.height * -cos(nfcXPos * PI).toFloat() / 6) + (phoneImgSize.height * -cos(nfcYPos * PI).toFloat() / 3)).toInt() + MaxLineLength:ClientCryptoTest.kt$ClientCryptoTest$assertTrue(it[0].matches("""1 0123456789 [a-f0-9]{32} [a-f0-9]{32} POST /Task/p\?something=123 HTTP/1.1""".toRegex())) + MaxLineLength:DataProtectionDifferences.kt$AnimatedVisibility + MaxLineLength:DebugScreen.kt$remember(viewModel.debugSettingsData.virtualHealthCardCert) { viewModel.getVirtualHealthCardCertificateSubjectInfo() } + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("19048979039598244295279281525021548448223459855185222892089532512446337024935426033638342846977861914875721218402342") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("21354446258743982691371413536748675410974765754620216137225614281636810686961198361153695003859088327367976229294869") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("21659270770119316173069236842332604979796116387017648600075645274821611501358515537962695117368903252229601718723941") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("3245789008328967059274849584342077916531909009637501918328323668736179176583263496463525128488282611559800773506973771797764811498834995234341530862286627") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("4480579927441533893329522230328287337018133311029754539518372936441756157459087304048546502931308754738349656551198") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("6294860557973063227666421306476379324074715770622746227136910445450301914281276098027990968407983962691151853678563877834221834027439718238065725844264138") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("6592244555240112873324748381429610341312712940326266331327445066687010545415256461097707483288650216992613090185042957716318301180159234788504307628509330") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("6792059140424575174435640431269195087843153390102521881468023012732047482579853077545647446272866794936371522410774532686582484617946013928874296844351522") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("717131854892629093329172042053689661426642816397448020844407951239049616491589607702456460799758882466071646850065") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$BigInteger("8948962207650232551656602815159153422162609644098354511344597187200057010413418528378981730643524959857451398370029280583094215613882043973354392115544169") + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$ECFieldFp(BigInteger("21659270770119316173069236842332604979796116387017648600081618503821089934025961822236561982844534088440708417973331")) + MaxLineLength:EllipticCurvesExtending.kt$EllipticCurvesExtending$ECFieldFp(BigInteger("8948962207650232551656602815159153422162609644098354511344597187200057010413552439917934304191956942765446530386427345937963894309923928536070534607816947")) + MaxLineLength:EncryptedPinFormat2.kt$EncryptedPinFormat2$require(intPin.size <= MAX_PIN_LEN) { "PIN length is too long, max length is " + MAX_PIN_LEN + ", but was " + intPin.size } + MaxLineLength:EncryptedPinFormat2.kt$EncryptedPinFormat2$require(intPin.size >= MIN_PIN_LEN) { "PIN length is too short, min length is " + MIN_PIN_LEN + ", but was " + intPin.size } + MaxLineLength:EncryptedPinFormat2.kt$EncryptedPinFormat2$require(it in MIN_DIGIT..MAX_DIGIT) { "PIN digit value is out of range of a decimal digit: ${(it + STRING_INT_OFFSET).toChar()}" } + MaxLineLength:FhirMapper.kt$(dosageInstruction?.getExtensionByUrl("https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_DosageFlag")?.value as? BooleanType?)?.value + MaxLineLength:FhirMapper.kt$FhirMapper$payload = runCatching { json.decodeFromString<CommunicationPayloadInbox>(fhirCommunication.payload.first().content.toString()) }.getOrNull() + MaxLineLength:FhirMapper.kt$dosageCode = this.form?.coding?.find { it.system == "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM" }?.code + MaxLineLength:FhirMapper.kt$normSizeCode = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/normgroesse")?.value as? CodeType?)?.value + MaxLineLength:FhirMapper.kt$statusCode = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/gkv/versichertenart")?.value as? Coding?)?.code + MaxLineLength:FhirMapper.kt$uniqueIdentifier = this.identifier?.find { it.system == "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR" }?.value + MaxLineLength:GeneralAuthenticateCommand.kt$data = DERTaggedObject(true, BERTags.APPLICATION, 28, DERTaggedObject(false, tagNo, DEROctetString(data))).encoded + MaxLineLength:HealthCardOrderComponents.kt$company.hasMailContentForCardAndPin() + MaxLineLength:HealthCardOrderComponents.kt$company.hasMailContentForPin() + MaxLineLength:HealthCardOrderComponents.kt$onClickInsuranceSelector = { navController.navigate(HealthCardOrderNavigationScreens.HealthCardOrderInsuranceCompanies.path()) } + MaxLineLength:HealthCardOrderUseCase.kt$fun + MaxLineLength:HealthInsuranceCompany.kt$HealthCardOrderUseCaseData.HealthInsuranceCompany$!healthCardAndPinPhone.isNullOrEmpty() || !healthCardAndPinMail.isNullOrEmpty() || !healthCardAndPinUrl.isNullOrEmpty() + MaxLineLength:HealthInsuranceCompany.kt$HealthCardOrderUseCaseData.HealthInsuranceCompany$fun hasMailContentForCardAndPin() + MaxLineLength:Hints.kt$body = { Text("Hier tippen, um sie in einer Apotheke einzulösen, Hier tippen, um sie in einer Apotheke einzulösen, Hier tippen, um sie in einer Apotheke einzulösen") } + MaxLineLength:IdpAlternateAuthenticationUseCase.kt$IdpAlternateAuthenticationUseCase$(JsonWebStructure.fromCompactSerialization(it.signedPairingData) as JsonWebSignature).unverifiedPayload + MaxLineLength:IdpBasicUseCase.kt$IdpBasicUseCase$return JsonWebStructure.fromCompactSerialization(Json.parseToJsonElement(json).jsonObject["njwt"]!!.jsonPrimitive.content) as JsonWebSignature + MaxLineLength:IdpRepositoryTest.kt$CommonIdpRepositoryTest$private val healthCardCert = X509CertificateHolder(Base64.decode(BuildKonfig.DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE)) + MaxLineLength:LicenceRule.kt$LicenceRule$commentChild != null && commentChild.lineNumber() == 1 && commentChild.text.trim() == licenceHeader.trim() + MaxLineLength:LocalDataSource.kt$AuditEventLocalDataSource.AuditPagingSource$override suspend + MaxLineLength:LoginWithHealthCardScreen.kt$. + MaxLineLength:LoginWithHealthCardScreen.kt$if + MaxLineLength:Main.kt$Row + MaxLineLength:MainScreenComponents.kt$if + MaxLineLength:Navigation.kt$Route$requireNotNull(arg.argument.type as? UriNavType) { "Parcelable types must be accompanied with an `UriNavType` argument." } + MaxLineLength:Navigation.kt$Route$requireNotNull(arguments.find { it.name == attr.first }) { "`${attr.first}` not specified within arguments." } + MaxLineLength:OCSPUtils.kt$require(this.isSignatureValid(verifier)) { "OCSP response signature couldn't be validated against its signer certificate" } + MaxLineLength:PharmacySearchUseCase.kt$PharmacySearchUseCase.PharmacyPagingSource$override + MaxLineLength:PharmacySearchUseCase.kt$PharmacySearchUseCase.PharmacyPagingSource$override suspend + MaxLineLength:PrescriptionDetailScreen.kt$"${prescription.medication.normSizeCode} - ${App.strings.normSizeMapping()[prescription.medication.normSizeCode]?.invoke()}" + MaxLineLength:PrescriptionUseCase.kt$PrescriptionUseCase$fun + MaxLineLength:PrescriptionUseCaseTest.kt$PrescriptionUseCaseTest$fun + MaxLineLength:ProfilesData.kt$ProfilesData.Profile$return "Profile(id='$id', color=$color, name='$name', insurantName=$insurantName, insuranceIdentifier=$insuranceIdentifier, insuranceName=$insuranceName, lastAuthenticated=$lastAuthenticated, lastAuditEventSynced=$lastAuditEventSynced, lastTaskSynced=$lastTaskSynced, active=$active, singleSignOnTokenScope=$singleSignOnTokenScope)" + MaxLineLength:ProtocolUseCase.kt$ProtocolUseCase.PharmacyPagingSource$override + MaxLineLength:ProtocolUseCase.kt$ProtocolUseCase.PharmacyPagingSource$override suspend + MaxLineLength:RemoteDataSource.kt$RemoteDataSource$"gt${timestamp.atOffset(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)}" + MaxLineLength:ScanScreenComponent.kt$Text(stringResource(R.string.cam_next_sheet_available_soon), Modifier.padding(horizontal = PaddingDefaults.Small, vertical = 2.dp)) + MaxLineLength:SecureMessagingTest.kt$SecureMessagingTest$"0D02030400012287820111013297D4AA774AB26AF8AD539C0A829BCA4D222D3EE2DB100CF86D7DB5A1FAC12B7623328DEFE3F6FDD41A993A" + MaxLineLength:SecureMessagingTest.kt$SecureMessagingTest$"29AA10B810D53EDB550FB741A68CC6B0BDF928F9EB6BC238416AACB4CF3002E865D486CF42D762C86EEBE6A2B25DECE2E88D569854A0" + MaxLineLength:SecureMessagingTest.kt$SecureMessagingTest$"7D3F146BC134BAF08B6EDCBEBDFF47EBA6AC7B441A1642B03253B588C49B69ABBEC92BA1723B7260DE8AD6158873141AFA7C70CFCF12" + MaxLineLength:SecureMessagingTest.kt$SecureMessagingTest$"C917BC17B364C3DD24740079DE60A3D0231A7185D36A77D37E147025913ADA00CD07736CFDE0DB2E0BB09B75C5773607E54A9D84181A" + MaxLineLength:SecureMessagingTest.kt$SecureMessagingTest$"CBC6F7726762A8BCE324C0B330548114154A13EDDBFF6DCBC3773DCA9A8494404BE4A5654273F9C2B9EBE1BD615CB39FFD0D3F2A0EEA" + MaxLineLength:SettingsRepository.kt$SettingsRepository$SettingsAuthenticationMethodV1.DeviceCredentials -> SettingsData.AuthenticationMode.DeviceCredentials + MaxLineLength:StringResource.kt$Singular("Beim Senden Ihrer Nachricht werden folgende Informationen über genutzte Hardware und Betriebssystem übertragen:") + MaxLineLength:StringResource.kt$Singular("Bir reçete çıktısı aldınız mı? İlgili reçete kodunu tarayarak reçetleri uygulamaya ekleyebilirsiniz.") + MaxLineLength:StringResource.kt$Singular("Bitte achten Sie darauf, dass Personen, mit denen Sie gegebenenfalls dieses Gerät teilen und deren biometrische Merkmale auf diesem Gerät gespeichert sein könnten oder die über Geräte-PIN, Wischmuster oder Passwort verfügen, ebenfalls Zugriff auf Ihre Rezepte erhalten.") + MaxLineLength:StringResource.kt$Singular("Bitte aktivieren Sie die NFC-Funktion Ihres Geräts, um sich mit Ihrer Gesundheitskarte anzumelden.") + MaxLineLength:StringResource.kt$Singular("Bitte beachten Sie die Einnahmehinweise in Ihrem Medikationsplan oder die schriftliche Dosierungsanweisung Ihres Arztes.") + MaxLineLength:StringResource.kt$Singular("Bitten Sie ausdrücklich um die Zusendung von Karte & PIN zum Zwecke der Nutzung der E-Rezept-App.") + MaxLineLength:StringResource.kt$Singular("Bu uygulamayı daha iyi hale getirmemize yardımcı olun. Tüm kullanım verileri anonim olarak toplanır ve yalnızca kullanıcı deneyimini iyileştirmek için hizmet verir.") + MaxLineLength:StringResource.kt$Singular("Bu vesileyle reçeteleriniz bu eczaneye gönderilecektir. Daha sonra bunları artık başka bir eczanede kullanamazsınız.") + MaxLineLength:StringResource.kt$Singular("Bu, telefonunuzun donanım ve yazılım bilgilerini, E-Rezept uygulamasının ayarlarını ve kullanım kapsamını içerir, ancak asla kişiliğiniz veya sağlığınızla ilgili verileri içermez.\nVeriler, veri işleyenler tarafından sadece gematik GmbH\'ye sunulur ve en geç 180 gün sonra silinir. Analizi istediğiniz zaman uygulama menüsünden devre dışı bırakabilirsiniz.\nBu veriler, hangi fonksiyonların sıklıkla kullanıldığını anlamamızı ve bunları geliştirmemizi sağlar. Ayrıca, daha eski teknolojinin ne kadar süreyle desteklenmesi gerektiğini ve örneğin ne zaman (çok fazla) kullanıcıyı etkilemeden daha yeni bir işletim sistemi sürümünü zorunlu hale getirmemiz gerektiğini tahmin edebiliriz.") + MaxLineLength:StringResource.kt$Singular("Burada elektronik reçeteleri seçtiğiniz bir eczanede, doğrudan sitede veya çevrim içi olarak kullanabilirsiniz.") + MaxLineLength:StringResource.kt$Singular("Cihazınızın sunucuya bağlanması için geçen süre, donanım ve internet hızına bağlı olarak değişebilir.") + MaxLineLength:StringResource.kt$Singular("Cinsiyet eşitliğine uygun bir dil kullanmaya çalışıyoruz. Herhangi bir hata fark ederseniz, sizden e-posta ile haber almaktan memnuniyet duyarız.") + MaxLineLength:StringResource.kt$Singular("Convenient and available to you soon: your data will be biometrically protected on the device for this purpose") + MaxLineLength:StringResource.kt$Singular("Das Verzeichnis der Gesundheitskarten konnte nicht erreicht werden. Bitte versuchen Sie es erneut.") + MaxLineLength:StringResource.kt$Singular("Das Zertifikat Ihrer Gesundheitskarte ist ungültig. Ist Ihre Karte möglicherweise abgelaufen? Bitte kontaktieren Sie Ihre Krankenkasse.") + MaxLineLength:StringResource.kt$Singular("Das hilft Ihnen dabei, den Überblick zu behalten, wenn Sie die Rezepte für mehrere Personen verwalten möchten.") + MaxLineLength:StringResource.kt$Singular("Das umfasst Hard- und Softwareinformationen Ihres Telefons, Einstellungen der E-Rezept App sowie Umfang der Nutzung, jedoch niemals Daten über Ihre Person oder Ihre Gesundheit.") + MaxLineLength:StringResource.kt$Singular("Das umfasst Hard- und Softwareinformationen Ihres Telefons, Einstellungen der E-Rezept App sowie Umfang der Nutzung, jedoch niemals Daten über Ihre Person oder Ihre Gesundheit.\nDie Daten werden durch Datenverarbeitungsnehmer nur der gematik GmbH zur Verfügung gestellt und nach 180 Tagen spätestens gelöscht. Sie können die Analyse jederzeit wieder im Menü der App deaktivieren.\nWir können durch diese Daten nachvollziehen, welche Funktionen häufig genutzt werden, und diese verbessern. Ferner können wir einschätzen, wie lange ältere Technik unterstützt werden muss, und wann wir z. B. eine neuere Betriebssystemversion verpflichtend machen können, ohne (zu viele) Nutzer zu betreffen.") + MaxLineLength:StringResource.kt$Singular("Dear Service Team, I received a message from a pharmacy. Unfortunately, however, I could not tell my user the message because I did not understand it. Please check what happened here and help us. Thank you very much! The e-prescription app") + MaxLineLength:StringResource.kt$Singular("Demo modu, elektronik sağlık kartı olmadan bile uygulamanın tüm alanlarını keşfetmenize olanak tanır.") + MaxLineLength:StringResource.kt$Singular("Der Demo-Modus erlaubt es Ihnen, alle Bereiche der App auch ohne elektronische Gesundheitskarte zu erkunden.") + MaxLineLength:StringResource.kt$Singular("Die Apotheke wird sich schnellstmöglich mit Ihnen in Verbindung setzen, um Einzelheiten zur Lieferung mit Ihnen zu klären.") + MaxLineLength:StringResource.kt$Singular("Die Daten werden durch Datenverarbeitungsnehmer nur der gematik GmbH zur Verfügung gestellt und nach 180 Tagen spätestens gelöscht. Sie können die Analyse jederzeit wieder im Menü der App deaktivieren.") + MaxLineLength:StringResource.kt$Singular("Die Gesundheitskarte und die zugehörige PIN erhalten Sie kostenfrei von Ihrer Krankenversicherung. Der Antrag kann formlos und per %s gestellt werden.") + MaxLineLength:StringResource.kt$Singular("Die Verbindung Ihres Geräts mit dem Server kann je nach Hardware und Internetgeschwindigkeit unterschiedlich lange dauern.") + MaxLineLength:StringResource.kt$Singular("Die Versandapotheke erstellt Ihnen einen Warenkorb mit Ihren Medikamenten. Dieser Vorgang kann einige Minuten dauern.") + MaxLineLength:StringResource.kt$Singular("Die beste verfügbare Geräteabsicherung wurde nicht eingerichtet. Hierbei kann es sich um Fingerabdruck, Wischmuster oder ähnliches handeln") + MaxLineLength:StringResource.kt$Singular("Die folgenden Informationen würde ich gerne dem Service-Team mitteilen, damit die Fehlersuche durchgeführt werden kann. Bitte beachten Sie, dass wir auch Ihre eMail-Adresse sowie ggf. Ihren Namen erfahren, wenn Sie ihn als Absender der eMail konfiguriert haben. Wenn Sie diese Informationen ganz oder teilweise nicht übermitteln möchten, löschen Sie diese bitte aus der Mail. Alle Daten werden von der gematik GmbH oder deren beauftragten Unternehmen nur zur Bearbeitung dieser Fehlermeldung gespeichert und verarbeitet. Die Löschung erfolgt automatisiert, spätestens 180 Tage nach Erledigung des Tickets. Ihre eMail-Adresse nutzen wir ausschließlich, um mit Ihnen Kontakt in Bezug auf diese Fehlermeldung aufzunehmen. Für Fragen oder eine vorzeitige Löschung können Sie sich jederzeit an den Datenschutzverantwortlichen des E-Rezept Systems wenden. Sie finden weitere Informationen in der E-Rezept App im Menü unter dem Datenschutz-Eintrag.") + MaxLineLength:StringResource.kt$Singular("Diese App verwendet die sicherste Methode, die von Ihrem Gerät zur Verfügung gestellt wird. Hierbei kann es sich um Fingerabdruck, Wischmuster oder ähnliches handeln.") + MaxLineLength:StringResource.kt$Singular("Diese erhalten Sie kostenfrei von Ihrer Krankenversicherung. Hierfür müssen Sie sich mittels amtlichem Ausweisdokument identifiziert haben.") + MaxLineLength:StringResource.kt$Singular("Dieses Profil wurde noch nicht mit einer Versichertennummer verbunden. Hierfür müssen Sie sich am Rezeptserver anmelden.") + MaxLineLength:StringResource.kt$Singular("Eine PIN für Ihre Gesundheitskarte erhalten Sie in einem separaten Brief von Ihrer Krankenversicherung.") + MaxLineLength:StringResource.kt$Singular("Ermöglicht das Vergrößern der App über das Zusammen- oder Auseinanderziehen der Finger (Pinch-to-Zoom).") + MaxLineLength:StringResource.kt$Singular("Ersatzpräparate sind zulässig. Aufgrund gesetzlicher Vorgaben Ihrer Krankenversicherung kann Ihnen eine Alternative ausgehändigt werden.") + MaxLineLength:StringResource.kt$Singular("Es kann zu einer Verzögerung kommen, bis eingelöste Rezepte im Bereich „Archiv“ angezeigt werden.") + MaxLineLength:StringResource.kt$Singular("Es werden alle Zugangsdaten zum Gesundheitsnetzwerk gelöscht. Ihre Rezeptdaten bleiben erhalten.") + MaxLineLength:StringResource.kt$Singular("Fachlich geprüfte Informationen zu Krankheiten, ICD-Codes und zu Vorsorge- und Pflegethemen finden Sie im Nationalen Gesundheitsportal.") + MaxLineLength:StringResource.kt$Singular("Für die Anmeldung benötigen Sie eine geeignete Karte mit NFC. Wir unterstützen Sie bei der Bestellung.") + MaxLineLength:StringResource.kt$Singular("Genel Müdür: Dr. med. Markus Leyck Dieken\n Kayıt mahkemesi: Amtsgericht Berlin-Charlottenburg\n Ticaret sicil no.: HRB 96351\n Satış vergisi kimlik numarası: DE241843684") + MaxLineLength:StringResource.kt$Singular("Geschäftsführer: Dr. med. Markus Leyck Dieken\nRegistergericht: Amtsgericht Berlin-Charlottenburg\nHandelsregister-Nr.: HRB 96351\nUmsatzsteueridentifikationsnummer: DE241843684") + MaxLineLength:StringResource.kt$Singular("Hand hält ein Smartphone in der Hand und authentifiziert sich mit der neuen elektronischen Gesundheitskarte in der App") + MaxLineLength:StringResource.kt$Singular("Have you received a prescription printout? You add prescriptions to the app by scanning the respective prescription code.") + MaxLineLength:StringResource.kt$Singular("Helfen Sie uns, diese App besser zu machen. Alle Nutzerdaten werden anonym erhoben und dienen ausschließlich der Verbesserung des Nutzungserlebnisses.") + MaxLineLength:StringResource.kt$Singular("Help us make this app better. All user data is collected anonymously and is used solely to improve the user experience.") + MaxLineLength:StringResource.kt$Singular("Here you can redeem electronic prescriptions at a pharmacy of your choice, directly in person or online.") + MaxLineLength:StringResource.kt$Singular("Hier können Sie elektronische Rezepte in einer Apotheke Ihrer Wahl einlösen, direkt vor Ort oder online.") + MaxLineLength:StringResource.kt$Singular("Hiermit stellen Sie eine Verbindung zum Gesundheitsnetzwerk her. Sie erhalten dadurch automatisch neue Rezepte oder Nachrichten.") + MaxLineLength:StringResource.kt$Singular("Hiermit werden Ihre Rezepte an diese Apotheke gesendet. Sie können sie anschließend in keiner anderen Apotheke mehr einlösen.") + MaxLineLength:StringResource.kt$Singular("Hiermit werden alle Daten des Profils auf diesem Gerät gelöscht. Ihre Rezepte im Gesundheitsnetzwerk bleiben erhalten.") + MaxLineLength:StringResource.kt$Singular("Hinweis für die Apotheken: Die Kontaktdaten und Informationen zu Apotheken beziehen wir von mein-apothekenportal.de des Deutschen Apothekenverbandes e.V. Sie haben einen Fehler entdeckt oder möchten Daten korrigieren?") + MaxLineLength:StringResource.kt$Singular("Ihre Bestellung liegt üblicherweise zeitnah für Sie bereit. Für einen genauen Termin kontaktieren Sie bitte die Apotheke.") + MaxLineLength:StringResource.kt$Singular("Ihre Kartenzugangsnummer (Card Access Number, kurz: CAN) hat 6 Stellen. Sie finden die CAN in der rechten oberen Ecke der Vorderseite Ihrer Gesundheitskarte. Steht hier keine sechsstellige Zugangsnummer, benötigen Sie eine neue Gesundheitskarte von Ihrer Krankenversicherung.") + MaxLineLength:StringResource.kt$Singular("Im Falle eines Absturzes oder eines Fehlers der App sendet uns die App Hinweise zu den Gründen. Zudem werden Betriebssystemversion und Angaben zur verwendeten Hardware gesendet.") + MaxLineLength:StringResource.kt$Singular("In order to use the app, please agree to the Terms of Use and confirm that you have read and understood the Privacy Policy. Only data that is essential for the functioning of the services is collected.") + MaxLineLength:StringResource.kt$Singular("In the event of a crash or an error in the app, the app sends us information about the reasons along with the operating system version and details of the hardware used.") + MaxLineLength:StringResource.kt$Singular("Kart erişim numaranız (Card Access Number, kısaca: CAN) 6 hanelidir. CAN\'ı sağlık sigortası kartınızın ön yüzünün sağ üst köşesinde bulacaksınız. Burada altı haneli bir erişim numarası yoksa sağlık sigortanızdan yeni bir sağlık kartına ihtiyacınız olacaktır.") + MaxLineLength:StringResource.kt$Singular("Kullanışlı ve yakında sizin için kullanılabilir: Bu amaçla verileriniz cihazda biyometrik olarak korunacaktır") + MaxLineLength:StringResource.kt$Singular("Legen Sie Profile für Ihre Familie oder Angehörige an. Melden Sie sich mit der Gesundheitskarte an, um online bestellen zu können.") + MaxLineLength:StringResource.kt$Singular("Leider erfüllt Ihr Smartphone die Mindestanforderungen für die Nutzung der E-Rezept-App mit Ihrer elektronischen Gesundheitskarte nicht.") + MaxLineLength:StringResource.kt$Singular("Liebes Service-Team, ich habe eine Nachricht von einer Apotheke erhalten. Leider konnte ich meinem Nutzer die Nachricht aber nicht mitteilen, da ich sie nicht verstanden habe. Bitte prüft, was hier passiert ist, und helft uns. Vielen Dank! Die E-Rezept App") + MaxLineLength:StringResource.kt$Singular("Lütfen bu cihazı paylaşabileceğiniz ve biyometrik özellikleri bu cihazda saklanabilecek veya cihaz PIN\'ini, kaydırma hareketini veya şifreyi bilen kişilerin de reçetelerinize erişebileceğini unutmayın.") + MaxLineLength:StringResource.kt$Singular("Managing Director: Dr. med. Markus Leyck Dieken\nRegister Court: District Court of Berlin-Charlottenburg\nCommercial register no.: HRB 96351\nVAT ID: DE241843684") + MaxLineLength:StringResource.kt$Singular("Muadillere izin verilir. Sağlık sigortanızın yasal gereklilikleri nedeniyle size bir alternatif verilebilir.") + MaxLineLength:StringResource.kt$Singular("Please be aware that people with whom you may share this device and whose biometrics may be stored on this device or who have the device PIN, swipe pattern or password may also have access to your prescriptions.") + MaxLineLength:StringResource.kt$Singular("Please follow the directions for use in your medication schedule or the written dosage instructions from your doctor.") + MaxLineLength:StringResource.kt$Singular("Pulver für ein Konzentrat zur Herstellung einer Infusionslösung Pulver zur Herstellung einer Lösung zum Einnehmen") + MaxLineLength:StringResource.kt$Singular("Pulver zur Herstellung einer Injektions- bzw. Infusionslösung oder Pulver und Lösungsmittel zur Herstellung einer Lösung zur intravesikalen Anwendung") + MaxLineLength:StringResource.kt$Singular("Pulver zur Herstellung einer Injektions- bzw. Infusionslösung oder einer Lösung zur intravesikalen Anwendung") + MaxLineLength:StringResource.kt$Singular("Senden Sie Ihr Rezept an eine Apotheke und entscheiden Sie, wie Sie Ihre Medikamente erhalten möchten.") + MaxLineLength:StringResource.kt$Singular("Sie haben Fragen oder Probleme bei der Nutzung der App? Unsere technische Hotline erreichen Sie unter %s.") + MaxLineLength:StringResource.kt$Singular("Sie haben einen Rezept-Ausdruck erhalten? Rezepte fügen Sie der App hinzu, indem Sie den jeweiligen Rezeptcode abscannen.") + MaxLineLength:StringResource.kt$Singular("Siparişiniz genellikle kısa sürede teslim almanız için hazırdır. Kesin randevu için lütfen eczane ile irtibata geçin.") + MaxLineLength:StringResource.kt$Singular("Submit your prescription to a pharmacy and decide how you would like to receive your medication.") + MaxLineLength:StringResource.kt$Singular("Substitutes are permitted. You may be given an alternative due to the legal requirements of your health insurance.") + MaxLineLength:StringResource.kt$Singular("Tarayıcıyı kullanabilmek için sistem ayarlarında uygulamanın kameranıza erişmesine izin vermelisiniz.") + MaxLineLength:StringResource.kt$Singular("The mail-order pharmacy will create a shopping cart for you with your medicines. This process may take a few minutes.") + MaxLineLength:StringResource.kt$Singular("The time it takes for your device to connect to the server can vary depending on the hardware and Internet speed.") + MaxLineLength:StringResource.kt$Singular("This includes information about your phone\'s hardware and software, settings of the e-prescription app as well as the extent of use, but never any personal or health data concerning you. \nThis data is made available exclusively to gematik GmbH by data processors and is deleted after 180 days at the latest. You can disable the analysis of your usage behaviour at any time in the settings menu of the app.\nWe can use this data to understand which functions are used frequently and improve them. Furthermore, we can assess how long older technology needs to be supported and when we can, for example, make a newer operating system version mandatory without affecting (too many) users.") + MaxLineLength:StringResource.kt$Singular("Tippen Sie auf „Warenkorb öffnen“ und schließen Sie Ihre Bestellung auf der Webseite der Apotheke ab.") + MaxLineLength:StringResource.kt$Singular("To be able to use all functions of the app, log in with your medical card. You will receive this card and the required login details from your health insurance company.") + MaxLineLength:StringResource.kt$Singular("Treiber des Kartenlesegeräts möglicherweise nicht geladen. Bitte verbinden Sie das Kartenlesegerät vor dem Start der E-Rezept-Anwendung.") + MaxLineLength:StringResource.kt$Singular("Um alle Funktionen der App nutzen zu können, melden Sie sich mit Ihrer Gesundheitskarte an. Diese Karte sowie die benötigen Zugangsdaten erhalten Sie von Ihrer Krankenversicherung.") + MaxLineLength:StringResource.kt$Singular("Um automatisch Rezepte zu empfangen und leicht Medikamente online einlösen oder reservieren zu können, müssen Sie sich anmelden.") + MaxLineLength:StringResource.kt$Singular("Um den Scanner verwenden zu können, müssen Sie der App in den Systemeinstellungen den Zugriff auf Ihre Kamera gestatten.") + MaxLineLength:StringResource.kt$Singular("Um die App nutzen zu können, stimmen Sie bitte den Nutzungsbedingungen zu und bestätigen Sie die Kenntnisnahme der Datenschutzbedingungen. Es werden nur Daten erfasst, die für das Funktionieren der Dienste unerlässlich sind.") + MaxLineLength:StringResource.kt$Singular("Um sich in dieser App anmelden zu können, benötigen Sie eine NFC-fähige Gesundheitskarte sowie eine zugehörige PIN.") + MaxLineLength:StringResource.kt$Singular("Uygulamada bir çökme veya hata olması durumunda uygulama bize nedenleri hakkında bilgi gönderir. Ayrıca işletim sistemi sürümü ve kullanılan donanımlar ile ilgili bilgiler de gönderilir.") + MaxLineLength:StringResource.kt$Singular("Uygulamanın tüm fonksiyonlarını kullanabilmek için sağlık kartınız ile giriş yapın. Bu kartı ve gerekli erişim verilerini sağlık sigortanızdan alacaksınız.") + MaxLineLength:StringResource.kt$Singular("Uygulamayı kullanmak için lütfen kullanım koşullarını kabul edin ve veri koruma politikasından haberdar olduğunuzu onaylayın. Yalnızca hizmetlerin çalışması için gerekli olan veriler toplanır.") + MaxLineLength:StringResource.kt$Singular("Warum gibt es Mindestanforderungen für die Verbindung von App und elektronischer Gesundheitskarte?") + MaxLineLength:StringResource.kt$Singular("We strive to use gender-sensitive language. If you notice any errors, we would be pleased to hear from you by email.") + MaxLineLength:StringResource.kt$Singular("Wir bemühen uns um eine geschlechtergerechte Sprache. Sollten Ihnen Fehler auffallen, freuen wir uns über eine Mitteilung per Mail.") + MaxLineLength:StringResource.kt$Singular("Wir empfehlen Ihnen, Ihre medizinischen Daten zusätzlich durch eine Gerätesicherung wie beispielsweise einen Code oder Biometrie zu schützen.") + MaxLineLength:StringResource.kt$Singular("Wir können durch diese Daten nachvollziehen, welche Funktionen häufig genutzt werden, und diese verbessern. Ferner können wir einschätzen, wie lange ältere Technik unterstützt werden muss, und wann wir z.B. eine neuere Betriebssystemversion verpflichtend machen können, ohne (zu viele) Nutzer zu betreffen.") + MaxLineLength:StringResource.kt$Singular("You will receive a PIN for your medical card in a separate letter from your health insurance company.") + MaxLineLength:StringResource.kt$Singular("Your card access number (CAN) has six digits. You will find the CAN in the top right-hand corner of the front of your medical card. If there is no six-digit access number here, you will need a new medical card from your health insurance company.") + MaxLineLength:StringResource.kt$Singular("Your order will usually be ready for you as soon as possible. Please contact the pharmacy for exact timings.") + MaxLineLength:StringResource.kt$Singular("Your prescriptions will be sent to this pharmacy. You will then not be able to redeem them in any other pharmacy.") + MaxLineLength:StringResource.kt$Singular("Zum Lesen des Rezeptcodes nutzt diese App einen Service von Google. Dabei werden Daten an Google übertragen.") + MaxLineLength:StringResource.kt$Singular("https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten/woran-erkenne-ich-ob-ich-eine-nfc-faehige-gesundheitskarte-habe#c204") + MaxLineLength:StringResource.kt$Singular("Çevrim içi eczanesi ilaçlarınızla birlikte bir alışveriş sepeti oluşturacaktır. Bu işlem birkaç dakika sürebilir.") + MaxLineLength:TestData.kt$TestCertificates.Idp1$"MIICsTCCAligAwIBAgIHA61I5ACUjTAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA4MDQwMDAwMDBaFw0yNTA4MDQyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAxMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABJZQrG1NWxIB3kz/6Z2zojlkJqN3vJXZ3EZnJ6JXTXw5ZDFZ5XjwWmtgfomv3VOV7qzI5ycUSJysMWDEu3mqRcajge0wgeowHQYDVR0OBBYEFJ8DVLAZWT+BlojTD4MT/Na+ES8YMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgUswCgYIKoIUAEwEgSMwHwYDVR0jBBgwFoAUKPD45qnId8xDRduartc6g6wOD6gwLQYFKyQIAwMEJDAiMCAwHjAcMBowDAwKSURQLURpZW5zdDAKBggqghQATASCBDAOBgNVHQ8BAf8EBAMCB4AwCgYIKoZIzj0EAwIDRwAwRAIgVBPhAwyX8HAVH0O0b3+VazpBAWkQNjkEVRkv+EYX1e8CIFdn4O+nivM+XVi9xiKK4dW1R7MD334OpOPTFjeEhIVV" + MaxLineLength:TestData.kt$TestCertificates.Idp2$"MIICsTCCAligAwIBAgIHAbssqQhqOzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTUwMDAwMDBaFw0yNjAxMTUyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAzMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABIYZnwiGAn5QYOx43Z8MwaZLD3r/bz6BTcQO5pbeum6qQzYD5dDCcriw/VNPPZCQzXQPg4StWyy5OOq9TogBEmOjge0wgeowDgYDVR0PAQH/BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIUAEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYBBQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFC94M9LgW44lNgoAbkPaomnLjS8/MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAwRAIgCg4yZDWmyBirgxzawz/S8DJnRFKtYU/YGNlRc7+kBHcCIBuzba3GspqSmoP1VwMeNNKNaLsgV8vMbDJb30aqaiX1" + MaxLineLength:TestData.kt$TestCertificates.OCSP1$"MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAxWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcDrUjkAJSNgAAYDzIwMjEwNTE3MDYyMzAxWqARGA8yMDIxMDUxNzA2MjMwMVqhIzAhMB8GCSsGAQUFBzABAgQSBBDkdyImUBsO+Q8iAA2xbXu8MAkGByqGSM49BAEDRwAwRAIgW+JlwUmnZCVsME2kOyQlcqF01Lel/0nQdE6IaZmFADECIGhOH1k5Dzq42y2jCxZCzxevRc6vY1o8ky0Xy4DxLIWJoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + MaxLineLength:TestData.kt$TestCertificates.OCSP1.SignerCert$val Base64 = "MIICmjCCAkCgAwIBAgIHA602RERCazAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA1MDYwMDAwMDBaFw0yMzA1MDYyMzU5NTlaMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkwWjAUBgcqhkjOPQIBBgkrJAMDAggBAQcDQgAEGwfkaELN0cr5DfqP1bNsWZS2XiuH6reLPZLHBSLkyFp/SzTKvNDdm7nKlp6Norg1z1njhyapRraaCzRS6VreD6OBxzCBxDAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDAOBgNVHQ8BAf8EBAMCBkAwFQYDVR0gBA4wDDAKBggqghQATASBIzATBgNVHSUEDDAKBggrBgEFBQcDCTAMBgNVHRMBAf8EAjAAMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAdBgNVHQ4EFgQUHKqJlrsbnD6bAIyF2WScrHtUmeIwCgYIKoZIzj0EAwIDSAAwRQIhAIkd0/4EtDLRRnb0B8mgmvlxepYrLKX/lkVGoXy0D64OAiAkOCmXOwGJExZxxRm4diJ/GPzZI4ecAnaVqnikYAQVCQ==" + MaxLineLength:TestData.kt$TestCertificates.OCSP2$"MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAxWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcBuyypCGo7gAAYDzIwMjEwNTE3MDYyMzAxWqARGA8yMDIxMDUxNzA2MjMwMVqhIzAhMB8GCSsGAQUFBzABAgQSBBDIsivTG9WljP4InmqVdKQmMAkGByqGSM49BAEDRwAwRAIgZMCyRhqMOaEG10KPz3mL5Yh7oX9fiIdBl8WrxLT2SewCIEvjzedVlnbt/j4e7VALo2xl8wvOcYe8gT04+PqH5vkfoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + MaxLineLength:TestData.kt$TestCertificates.OCSP3$"MIIEFgoBAKCCBA8wggQLBgkrBgEFBQcwAQEEggP8MIID+DCB+6FZMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkYDzIwMjEwNTE3MDYyMzAwWjBoMGYwPjAHBgUrDgMCGgQUXI3/vEvbfD7eUzpI1js5mj5OUC4EFCjw+OapyHfMQ0Xbmq7XOoOsDg+oAgcBPCti7yC3gAAYDzIwMjEwNTE3MDYyMzAwWqARGA8yMDIxMDUxNzA2MjMwMFqhIzAhMB8GCSsGAQUFBzABAgQSBBAWpjYsPzj/U96/S1MvypTWMAkGByqGSM49BAEDRwAwRAIgXfEC3h/1H2/aHGEyJY9L59S6NbqdkStBBk2vczj+3mwCIASMGDqPuhA7ZLBJ5HhHpwKYEQw/YPluyBMnz7j2dXtPoIICojCCAp4wggKaMIICQKADAgECAgcDrTZEREJrMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMB4XDTIwMDUwNjAwMDAwMFoXDTIzMDUwNjIzNTk1OVowVzELMAkGA1UEBhMCREUxGjAYBgNVBAoMEWdlbWF0aWsgTk9ULVZBTElEMSwwKgYDVQQDDCNPQ1NQIFNpZ25lciBLb21wLUNBMTAgZWNjIFRFU1QtT05MWTBaMBQGByqGSM49AgEGCSskAwMCCAEBBwNCAAQbB+RoQs3RyvkN+o/Vs2xZlLZeK4fqt4s9kscFIuTIWn9LNMq80N2bucqWno2iuDXPWeOHJqlGtpoLNFLpWt4Po4HHMIHEMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMA4GA1UdDwEB/wQEAwIGQDAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMBMGA1UdJQQMMAoGCCsGAQUFBwMJMAwGA1UdEwEB/wQCMAAwOAYIKwYBBQUHAQEELDAqMCgGCCsGAQUFBzABhhxodHRwOi8vZWhjYS5nZW1hdGlrLmRlL29jc3AvMB0GA1UdDgQWBBQcqomWuxucPpsAjIXZZJyse1SZ4jAKBggqhkjOPQQDAgNIADBFAiEAiR3T/gS0MtFGdvQHyaCa+XF6lisspf+WRUahfLQPrg4CICQ4KZc7AYkTFnHFGbh2In8Y/Nkjh5wCdpWqeKRgBBUJ" + MaxLineLength:TestData.kt$TestCertificates.Vau$"MIIC7jCCApWgAwIBAgIHATwrYu8gtzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDEwMDcwMDAwMDBaFw0yNTA4MDcwMDAwMDBaMF4xCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDEnMCUGA1UEAwweRVJQIFJlZmVyZW56ZW50d2lja2x1bmcgRkQgRW5jMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABKYLzjl704qFX+oEuUOyLV70i2Bn2K4jekh/YOxExtdADB3X/q7fX/tVr09GtDRxe3h1yov9TwuHaHYh91RlyMejggEUMIIBEDAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgSMwCgYIKoIUAEwEgUowHQYDVR0OBBYEFK5+wVL9g8tGve6b1MdHK1xs62H7MDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAOBgNVHQ8BAf8EBAMCAwgwUwYFKyQIAwMESjBIMEYwRDBCMEAwMgwwRS1SZXplcHQgdmVydHJhdWVuc3fDvHJkaWdlIEF1c2bDvGhydW5nc3VtZ2VidW5nMAoGCCqCFABMBIICMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMAoGCCqGSM49BAMCA0cAMEQCIGZ20lLY2WEAGOTmNEFBB1EeU645fE0Iy2U9ypFHMlw4AiAVEP0HYut0Z8sKUk6WVanMmKXjfxO/qgQFzjsbq954dw==" + MaxLineLength:TestData.kt$TestCertificates.Vau$MIIC7jCCApWgAwIBAgIHATwrYu8gtzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDEwMDcwMDAwMDBaFw0yNTA4MDcwMDAwMDBaMF4xCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDEnMCUGA1UEAwweRVJQIFJlZmVyZW56ZW50d2lja2x1bmcgRkQgRW5jMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABKYLzjl704qFX+oEuUOyLV70i2Bn2K4jekh/YOxExtdADB3X/q7fX/tVr09GtDRxe3h1yov9TwuHaHYh91RlyMejggEUMIIBEDAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgSMwCgYIKoIUAEwEgUowHQYDVR0OBBYEFK5+wVL9g8tGve6b1MdHK1xs62H7MDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAOBgNVHQ8BAf8EBAMCAwgwUwYFKyQIAwMESjBIMEYwRDBCMEAwMgwwRS1SZXplcHQgdmVydHJhdWVuc3fDvHJkaWdlIEF1c2bDvGhydW5nc3VtZ2VidW5nMAoGCCqCFABMBIICMB8GA1UdIwQYMBaAFCjw+OapyHfMQ0Xbmq7XOoOsDg+oMAoGCCqGSM49BAMCA0cAMEQCIGZ20lLY2WEAGOTmNEFBB1EeU645fE0Iy2U9ypFHMlw4AiAVEP0HYut0Z8sKUk6WVanMmKXjfxO/qgQFzjsbq954dw== + MaxLineLength:TestData.kt$TestCertificates.Vau$MIICsTCCAligAwIBAgIHA61I5ACUjTAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA4MDQwMDAwMDBaFw0yNTA4MDQyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAxMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABJZQrG1NWxIB3kz/6Z2zojlkJqN3vJXZ3EZnJ6JXTXw5ZDFZ5XjwWmtgfomv3VOV7qzI5ycUSJysMWDEu3mqRcajge0wgeowHQYDVR0OBBYEFJ8DVLAZWT+BlojTD4MT/Na+ES8YMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAMBgNVHRMBAf8EAjAAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgUswCgYIKoIUAEwEgSMwHwYDVR0jBBgwFoAUKPD45qnId8xDRduartc6g6wOD6gwLQYFKyQIAwMEJDAiMCAwHjAcMBowDAwKSURQLURpZW5zdDAKBggqghQATASCBDAOBgNVHQ8BAf8EBAMCB4AwCgYIKoZIzj0EAwIDRwAwRAIgVBPhAwyX8HAVH0O0b3+VazpBAWkQNjkEVRkv+EYX1e8CIFdn4O+nivM+XVi9xiKK4dW1R7MD334OpOPTFjeEhIVV + MaxLineLength:TestData.kt$TestCertificates.Vau$MIICsTCCAligAwIBAgIHAbssqQhqOzAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMTAxMTUwMDAwMDBaFw0yNjAxMTUyMzU5NTlaMEkxCzAJBgNVBAYTAkRFMSYwJAYDVQQKDB1nZW1hdGlrIFRFU1QtT05MWSAtIE5PVC1WQUxJRDESMBAGA1UEAwwJSURQIFNpZyAzMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABIYZnwiGAn5QYOx43Z8MwaZLD3r/bz6BTcQO5pbeum6qQzYD5dDCcriw/VNPPZCQzXQPg4StWyy5OOq9TogBEmOjge0wgeowDgYDVR0PAQH/BAQDAgeAMC0GBSskCAMDBCQwIjAgMB4wHDAaMAwMCklEUC1EaWVuc3QwCgYIKoIUAEwEggQwIQYDVR0gBBowGDAKBggqghQATASBSzAKBggqghQATASBIzAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDA4BggrBgEFBQcBAQQsMCowKAYIKwYBBQUHMAGGHGh0dHA6Ly9laGNhLmdlbWF0aWsuZGUvb2NzcC8wHQYDVR0OBBYEFC94M9LgW44lNgoAbkPaomnLjS8/MAwGA1UdEwEB/wQCMAAwCgYIKoZIzj0EAwIDRwAwRAIgCg4yZDWmyBirgxzawz/S8DJnRFKtYU/YGNlRc7+kBHcCIBuzba3GspqSmoP1VwMeNNKNaLsgV8vMbDJb30aqaiX1 + MaxLineLength:TestData.kt$TestCertificates.Vau$MIIDGjCCAr+gAwIBAgIBFzAKBggqhkjOPQQDAjCBgTELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxNDAyBgNVBAsMK1plbnRyYWxlIFJvb3QtQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxGzAZBgNVBAMMEkdFTS5SQ0EzIFRFU1QtT05MWTAeFw0xNzA4MzAxMTM2MjJaFw0yNTA4MjgxMTM2MjFaMIGEMQswCQYDVQQGEwJERTEfMB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9uZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dFTS5LT01QLUNBMTAgVEVTVC1PTkxZMFowFAYHKoZIzj0CAQYJKyQDAwIIAQEHA0IABDFinQgzfsT1CN0QWwdm7e2JiaDYHocCiy1TWpOPyHwoPC54RULeUIBJeX199Qm1FFpgeIRP1E8cjbHGNsRbju6jggEgMIIBHDAdBgNVHQ4EFgQUKPD45qnId8xDRduartc6g6wOD6gwHwYDVR0jBBgwFoAUB5AzLXVTXn/4yDe/fskmV2jfONIwQgYIKwYBBQUHAQEENjA0MDIGCCsGAQUFBzABhiZodHRwOi8vb2NzcC5yb290LWNhLnRpLWRpZW5zdGUuZGUvb2NzcDASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAVBgNVHSAEDjAMMAoGCCqCFABMBIEjMFsGA1UdEQRUMFKgUAYDVQQKoEkMR2dlbWF0aWsgR2VzZWxsc2NoYWZ0IGbDvHIgVGVsZW1hdGlrYW53ZW5kdW5nZW4gZGVyIEdlc3VuZGhlaXRza2FydGUgbWJIMAoGCCqGSM49BAMCA0kAMEYCIQCprLtIIRx1Y4mKHlNngOVAf6D7rkYSa723oRyX7J2qwgIhAKPi9GSJyYp4gMTFeZkqvj8pcAqxNR9UKV7UYBlHrdxC + MaxLineLength:TestData.kt$TestCrypto$"01 754e548941e5cd073fed6d734578a484be9f0bbfa1b6fa3168ed7ffb22878f0f 9aef9bbd932a020d8828367bd080a3e72b36c41ee40c87253f9b1b0beb8371bf 257db4604af8ae0dfced37ce 86c2b491c7a8309e750b 4e6e307219863938c204dfe85502ee0a" + MaxLineLength:TestResource.kt$ApduResultEnum$ACTIVATECOMMAND_APDU + MaxLineLength:TestResource.kt$ParameterEnum$PARAMETER_BYTEARRAY_DEFAULT + MaxLineLength:TestResource.kt$ParameterEnum$PARAMETER_INT_OFFSET + MaxLineLength:TopBars.kt$val tabNames = listOf(stringResource(string.mainscreen_tab_redeemable), stringResource(string.mainscreen_tab_archive)) + MaxLineLength:TruststoreTest.kt$TruststoreTest$fun + MaxLineLength:TruststoreUseCase.kt$TruststoreUseCase$requireNotNull(store.idpCertificates.find { it == idpCertificate }) { "IDP certificate could not be validated" } + MaxLineLength:TwoDCodeProcessor.kt$TwoDCodeProcessor$Pair(FilteredDMCode(value = it, boundingBox = code.boundingBox!!, cornerPoints = code.cornerPoints!!), currTime) + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$" \"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea\",\n" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$" \"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea\"\n" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$" \"Task/2aef43b8c5e8f263d7aef64598b3c40e1d9e348f75d62fd39fe4a7bc5c923de8/\$accept?ac=0936cfa582b447144b71ac89eb7bb83a77c67c99d4054f91ee3703acf5d6a629\",\n" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$" \"Task/2aef43b8c5e8f2d3d7aef64598b3c40e1d9e348f75d62fd39fe4a7bc5c923de8/\$accept?ac=0936cfa582b447144b71ac89eb7bb83a77c67c99d4054f91ee3703acf5d6a629\",\n" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$" \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\",\n" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$" \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\"\n" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$"Task/2aef43b8c5e8f2d3d7aef64598b3c40e1d9e348f75d62fd39fe4a7bc5c923de8/\$accept?ac=0936cfa582b447144b71ac89eb7bb83a77c67c99d4054f91ee3703acf5d6a629" + MaxLineLength:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5" + MaxLineLength:Version2Test.kt$Version2Test$HealthCardVersion2.of(Hex.decode("EF2BC003020000C103040302C21045474B47322020202020202020010304C403010000C503020000C703010000")) + MayBeConst:TestData.kt$TestCertificates.OCSP1$val CertToCheckSerialNumber = "1034953504625805" // IDP 1 + MayBeConst:TestData.kt$TestCertificates.OCSP1.SignerCert$val Base64 = "MIICmjCCAkCgAwIBAgIHA602RERCazAKBggqhkjOPQQDAjCBhDELMAkGA1UEBhMCREUxHzAdBgNVBAoMFmdlbWF0aWsgR21iSCBOT1QtVkFMSUQxMjAwBgNVBAsMKUtvbXBvbmVudGVuLUNBIGRlciBUZWxlbWF0aWtpbmZyYXN0cnVrdHVyMSAwHgYDVQQDDBdHRU0uS09NUC1DQTEwIFRFU1QtT05MWTAeFw0yMDA1MDYwMDAwMDBaFw0yMzA1MDYyMzU5NTlaMFcxCzAJBgNVBAYTAkRFMRowGAYDVQQKDBFnZW1hdGlrIE5PVC1WQUxJRDEsMCoGA1UEAwwjT0NTUCBTaWduZXIgS29tcC1DQTEwIGVjYyBURVNULU9OTFkwWjAUBgcqhkjOPQIBBgkrJAMDAggBAQcDQgAEGwfkaELN0cr5DfqP1bNsWZS2XiuH6reLPZLHBSLkyFp/SzTKvNDdm7nKlp6Norg1z1njhyapRraaCzRS6VreD6OBxzCBxDAfBgNVHSMEGDAWgBQo8Pjmqch3zENF25qu1zqDrA4PqDAOBgNVHQ8BAf8EBAMCBkAwFQYDVR0gBA4wDDAKBggqghQATASBIzATBgNVHSUEDDAKBggrBgEFBQcDCTAMBgNVHRMBAf8EAjAAMDgGCCsGAQUFBwEBBCwwKjAoBggrBgEFBQcwAYYcaHR0cDovL2VoY2EuZ2VtYXRpay5kZS9vY3NwLzAdBgNVHQ4EFgQUHKqJlrsbnD6bAIyF2WScrHtUmeIwCgYIKoZIzj0EAwIDSAAwRQIhAIkd0/4EtDLRRnb0B8mgmvlxepYrLKX/lkVGoXy0D64OAiAkOCmXOwGJExZxxRm4diJ/GPzZI4ecAnaVqnikYAQVCQ==" + MayBeConst:TestData.kt$TestCertificates.OCSP2$val CertToCheckSerialNumber = "487275465566779" // IDP 2 + MayBeConst:TestData.kt$TestCertificates.OCSP3$val CertToCheckSerialNumber = "347632017809591" // VAU + MemberNameEqualsClassName:AppDependenciesPlugin.kt$AppDependenciesPlugin.Dependencies.Lottie$const val lottie = "com.airbnb.android:lottie-compose:$lottieVersion" + MemberNameEqualsClassName:Navigation.kt$Route$val route = arguments.fold(Uri.Builder().path(path)) { uri, param -> uri.appendQueryParameter(param.name, "{${param.name}}") }.build().toString() + NestedBlockDepth:EntityUtils.kt$private suspend fun SequenceScope<Deleteable>.flatten( currentObject: Cascading, currentDepth: Int, maxDepth: Int ) + NestedBlockDepth:LicenceRule.kt$LicenceRule$override fun visit( node: ASTNode, autoCorrect: Boolean, emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit ) + NestedBlockDepth:Utils.kt$ fun ByteArray.contains(other: ByteArray): Boolean + ReturnCount:BiometricPrompt.kt$private fun bestSecureOption(biometricManager: BiometricManager): Int + ReturnCount:TestResource.kt$TestResource$fun getParameter(parameterEnum: ParameterEnum): Any? + ReturnCount:Utils.kt$ fun ByteArray.contains(other: ByteArray): Boolean + SpreadOperator:AndroidStringResourceGeneratorTask.kt$AndroidStringResourceGeneratorTask$( "%L to Plurals(${tr.items.toTemplateString()}),", primaryUniqueNamesWithId.getValue(tr.name), *tr.items.toArgArray() ) + SpreadOperator:AndroidStringResourceGeneratorTask.kt$AndroidStringResourceGeneratorTask$("val strings = mapOf($format)", *values) + SpreadOperator:Common.kt$(id, *(args.map { AnnotatedString(it.toString()) }.toTypedArray())) + SwallowedException:CertUtils.kt$e: Exception + SwallowedException:IdpLocalDataSource.kt$IdpLocalDataSource$e: Exception + SwallowedException:OCSPUtils.kt$e: Exception + SwallowedException:SecureMessagingTest.kt$SecureMessagingTest$e: Exception + SwallowedException:TruststoreUseCase.kt$TruststoreUseCase$e: Exception + ThrowsCount:IdpUseCase.kt$IdpUseCase$private suspend fun loadAccessToken( refresh: Boolean = false, profileId: ProfileIdentifier, scope: IdpScope, singleSignOnTokenScope: suspend () -> IdpData.SingleSignOnTokenScope?, decryptedAccessToken: suspend () -> String?, invalidateDecryptedAccessToken: suspend () -> Unit, invalidateSingleSignOnTokenRetainingScope: suspend () -> Unit, saveDecryptedAccessToken: suspend (decryptedAccessToken: String) -> Unit ): String + TooGenericExceptionCaught:AuthenticationUseCase.kt$AuthenticationUseCase$e: Exception + TooGenericExceptionCaught:CertUtils.kt$e: Exception + TooGenericExceptionCaught:DebugLoadingButton.kt$e: Exception + TooGenericExceptionCaught:DebugScreen.kt$e: Exception + TooGenericExceptionCaught:DebugSettingsViewModel.kt$DebugSettingsViewModel$e: Exception + TooGenericExceptionCaught:EllipticCurvesExtending.kt$EllipticCurvesExtending$e: Exception + TooGenericExceptionCaught:IdpBasicUseCase.kt$IdpBasicUseCase$e: Exception + TooGenericExceptionCaught:IdpLocalDataSource.kt$IdpLocalDataSource$e: Exception + TooGenericExceptionCaught:IdpUseCase.kt$IdpUseCase$e: Exception + TooGenericExceptionCaught:LoginWithHealthCardViewModel.kt$LoginWithHealthCardViewModel$e: Exception + TooGenericExceptionCaught:NetworkUtil.kt$e: Exception + TooGenericExceptionCaught:OCSPUtils.kt$e: Exception + TooGenericExceptionCaught:PharmacyMapper.kt$e: Exception + TooGenericExceptionCaught:QueryUtils.kt$t: Throwable + TooGenericExceptionCaught:SafeApiCall.kt$e: Exception + TooGenericExceptionCaught:SharePrescriptionController.kt$SharePrescriptionController$e: IndexOutOfBoundsException + TooGenericExceptionCaught:TruststoreUseCase.kt$TruststoreUseCase$e: Exception + TooGenericExceptionCaught:TruststoreUseCase.kt$e: Exception + TooGenericExceptionCaught:TwoDCodeScanner.kt$TwoDCodeScanner$e: Exception + TooGenericExceptionCaught:TwoDCodeValidator.kt$TwoDCodeValidator$e: Exception + TooGenericExceptionCaught:VauChannelInterceptor.kt$VauChannelInterceptor$e: Exception + TooManyFunctions:AppDependenciesPlugin.kt$App$App + TooManyFunctions:Common.kt$de.gematik.ti.erp.app.utils.compose.Common.kt + TooManyFunctions:DebugSettingsViewModel.kt$DebugSettingsViewModel : ViewModel + TooManyFunctions:EditProfileScreen.kt$de.gematik.ti.erp.app.profiles.ui.EditProfileScreen.kt + TooManyFunctions:FhirMapper.kt$de.gematik.ti.erp.app.fhir.FhirMapper.kt + TooManyFunctions:Hints.kt$de.gematik.ti.erp.app.utils.compose.Hints.kt + TooManyFunctions:IdpAlternateAuthenticationUseCase.kt$IdpAlternateAuthenticationUseCase + TooManyFunctions:IdpBasicUseCase.kt$IdpBasicUseCase + TooManyFunctions:IdpRemoteDataSource.kt$IdpRemoteDataSource + TooManyFunctions:IdpRepository.kt$IdpRepository + TooManyFunctions:PrescriptionDetailScreen.kt$de.gematik.ti.erp.app.prescription.ui.PrescriptionDetailScreen.kt + TooManyFunctions:SettingsScreen.kt$de.gematik.ti.erp.app.settings.ui.SettingsScreen.kt + TooManyFunctions:SettingsViewModel.kt$SettingsViewModel : ViewModel + TopLevelPropertyNaming:ClientCrypto.kt$private const val byteSpace: Byte = 32 + TopLevelPropertyNaming:DebugScreen.kt$private const val maxNumberOfVisualLogs = 25 + TopLevelPropertyNaming:HeadersInterceptor.kt$private const val invalidAccessTokenHeader = "Www-Authenticate" + TopLevelPropertyNaming:HeadersInterceptor.kt$private const val invalidAccessTokenValue = "Bearer realm='prescriptionserver.telematik', error='invalACCESS_TOKEN'" + TopLevelPropertyNaming:IdpRemoteDataSource.kt$private const val defaultScope = "e-rezept openid" + TopLevelPropertyNaming:IdpRemoteDataSource.kt$private const val pairingScope = "pairing openid" + TopLevelPropertyNaming:PrescriptionDetailScreen.kt$private const val missingValue = "---" + UnnecessaryAbstractClass:TestDB.kt$TestDB + UnusedPrivateMember:DebugScreenWrapper.kt$navigation: NavController + UnusedPrivateMember:Hints.kt$innerPadding: PaddingValues + UnusedPrivateMember:PrescriptionDetailScreen.kt$@Composable private fun Group( content: @Composable ColumnScope.() -> Unit ) + UnusedPrivateMember:PrescriptionDetailScreen.kt$navigation: Navigation + UnusedPrivateMember:SafetynetReport.kt$SafetynetReport$private val apkPackageName = jwtContext.jwtClaims.getClaimValue("apkPackageName")?.toString() + UnusedPrivateMember:SafetynetReport.kt$SafetynetReport$private val error = jwtContext.jwtClaims.getClaimValue("error")?.toString() + UnusedPrivateMember:TestData.kt$sentOn: Instant? + UnusedPrivateMember:TestResource.kt$TestResource.Companion$private const val ID_PIN_CH = 1 + UnusedPrivateMember:Theme.kt$private val LocalAppTypographyColors = staticCompositionLocalOf<AppTypographyColors> { error("No AppTypographyColors provided") } + UnusedPrivateMember:TruststoreUseCase.kt$TrustedTruststore.Companion$val filteredAddRoots = untrustedCertList.addRoots.validateSubjectDN(RCA_PREFIX).distinct() + UnusedPrivateMember:TruststoreUseCase.kt$private const val RCA_PREFIX = "GEM.RCA" + UnusedPrivateMember:TwoDCodeValidatorTest.kt$TwoDCodeValidatorTest$private val notWellFormatted = ScannedCode( "{\n" + " \"urls\": [\n" + " \"Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea\",\n" + " \"Task/5e78f21cd6abc35edf4f1726c3d451ea2736d547a263f45726bc13a47e65d189/\$accept?ac=d3e6092ae3af14b5225e2ddbe5a4f59b3939a907d6fdd5ce6a760ca71f45d8e5\",\n" + " ]\n" + "}", Instant.now() ) + VariableNaming:CommandApduTest.kt$CommandApduTest$val header_plus_lc = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x00, (dataSize shr 8).toByte(), dataSize.toByte()) + VariableNaming:CommandApduTest.kt$CommandApduTest$val header_plus_lc = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x00, 0x00, 0xFF.toByte()) + VariableNaming:CommandApduTest.kt$CommandApduTest$val header_plus_lc = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x00, 0x01, 0x00) + VariableNaming:CommandApduTest.kt$CommandApduTest$val header_plus_lc = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x00, 0xFF.toByte(), 0xFF.toByte()) + VariableNaming:CommandApduTest.kt$CommandApduTest$val header_plus_lc = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0xFF.toByte()) + VariableNaming:CommandApduTest.kt$CommandApduTest$val header_plus_lc = byteArrayOf(0x01, 0x02, 0x03, 0x04, cmdData.size.toByte()) + VariableNaming:DispatchProvider.kt$DispatchProvider$val Default: CoroutineDispatcher get() = Dispatchers.Default + VariableNaming:DispatchProvider.kt$DispatchProvider$val IO: CoroutineDispatcher get() = Dispatchers.IO + VariableNaming:DispatchProvider.kt$DispatchProvider$val Main: CoroutineDispatcher get() = Dispatchers.Main + VariableNaming:DispatchProvider.kt$DispatchProvider$val Unconfined: CoroutineDispatcher get() = Dispatchers.Unconfined + VariableNaming:FhirMapper.kt$FhirMapper$private val COMMUNICATION_TYPE_DISP_REQ = "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" + VariableNaming:FhirMapper.kt$FhirMapper$private val COMMUNICATION_TYPE_REPLY = "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply" + + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 00000000..f76f9707 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,676 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ComplexMethod: + active: true + threshold: 18 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: true + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 120 + LongParameterList: + active: true + functionThreshold: 12 + constructorThreshold: 7 + ignoreDefaultParameters: true + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + NestedBlockDepth: + active: true + threshold: 4 + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + thresholdInFiles: 15 + thresholdInClasses: 15 + thresholdInInterfaces: 15 + thresholdInObjects: 15 + thresholdInEnums: 15 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: false + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: false + SleepInsteadOfDelay: + active: true + SuspendFunWithFlowReturnType: + active: false + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' + excludeClassPattern: '$^' + ignoreOverridden: true + ignoreAnnotated: ['Composable'] + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + InvalidPackageDeclaration: + active: false + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: false + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: false + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][A-Za-z0-9]*|[_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '(_)?[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: false + forbiddenTypePatterns: + - 'kotlin.String' + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: false + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + IgnoredReturnValue: + active: false + restrictToAnnotatedMethods: true + returnValueAnnotations: + - '*.CheckResult' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - '*.CanIgnoreReturnValue' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: false + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + MissingWhenCase: + active: true + allowElseExpression: true + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + RedundantElseInWhen: + active: true + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: true + +style: + active: true + CanBeNonNullable: + active: false + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: 'to' + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: false + maxDestructuringEntries: 3 + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: true + values: + - 'FIXME:' + - 'STOPSHIP:' + allowedPatterns: '' + customMessage: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: true + methods: + - 'kotlin.io.print' + - 'kotlin.io.println' + ForbiddenPublicDataClass: + active: true + excludes: ['**'] + ignorePackages: + - '*.internal' + - '*.internal.*' + ForbiddenVoid: + active: false + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: false + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: '' + LibraryCodeMustSpecifyReturnType: + active: true + excludes: ['**'] + LibraryEntitiesShouldNotBePublic: + active: true + excludes: ['**'] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreNumbers: + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesIfStatements: + active: true + MandatoryBracesLoops: + active: true + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + ObjectLiteralToLambda: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 4 + excludeGuardClauses: false + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryFilter: + active: false + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + ignoreAnnotated: ['Preview'] + allowedNames: '(_|ignored|expected|serialVersionUID)' + UseAnyOrNoneInsteadOfFind: + active: false + UseArrayLiteralsInAnnotations: + active: false + UseCheckNotNull: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + UseIsNullOrEmpty: + active: false + UseOrEmpty: + active: false + UseRequire: + active: false + UseRequireNotNull: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**'] + excludeImports: + - 'java.util.*' \ No newline at end of file diff --git a/desktop/AppxManifest.xml b/desktop/AppxManifest.xml index 6f36598e..461ddcfa 100644 --- a/desktop/AppxManifest.xml +++ b/desktop/AppxManifest.xml @@ -2,7 +2,7 @@ - + E-Rezept gematik GmbH diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index cb556cfe..45c22b55 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -35,7 +35,7 @@ tasks.withType { stringResPath("values/strings_desktop.xml") to Locale.GERMAN, stringResPath("values/strings_kbv_codes.xml") to Locale.GERMAN, stringResPath("values-en/strings.xml") to Locale.ENGLISH, - stringResPath("values-tr/strings.xml") to Locale.forLanguageTag("tr"), + stringResPath("values-tr/strings.xml") to Locale.forLanguageTag("tr") ) outputPath = file(project.projectDir.path + "/src/jvmMain/kotlin") packagePath = "de.gematik.ti.erp.app.common.strings" @@ -61,32 +61,30 @@ kotlin { kotlinOptions.jvmTarget = "15" kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" } + withJava() } sourceSets { val jvmMain by getting { dependencies { implementation(project(":common")) - implementation(kotlin("stdlib")) // TODO move to multiplatform lib for nfc implementation("de.gematik.ti.erp.app:smartcard-wrapper:1.0") - implementation("androidx.paging:paging-common:3.1.0") - implementation("androidx.paging:paging-common-ktx:3.1.0") - implementation(compose.desktop.currentOs) implementation(compose.desktop.common) - implementation(compose.runtime) + implementation(compose.materialIconsExtended) app { androidX { - implementation(paging("common")) - implementation(paging("common-ktx")) + compileOnly(paging("common-ktx")) + } + kotlinX { + coroutines("swing") } dependencyInjection { - implementation(kodein("di")) - implementation(kodein("di-framework-compose")) + compileOnly(kodein("di-framework-compose")) } dataMatrix { implementation(zxing) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/DownloadUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/DownloadUseCase.kt index 8d68dea3..0f3d0214 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/DownloadUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/DownloadUseCase.kt @@ -38,5 +38,8 @@ class DownloadUseCase( communicationRepository.download() } ).find { it.isFailure } ?: Result.success(Unit) + }.onFailure { + prescriptionRepository.invalidate() + communicationRepository.invalidate() } } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/cardwall/AuthenticationUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/cardwall/AuthenticationUseCase.kt index 0c5bb8ea..4e2516c2 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/cardwall/AuthenticationUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/cardwall/AuthenticationUseCase.kt @@ -18,15 +18,15 @@ package de.gematik.ti.erp.app.cardwall +import de.gematik.ti.erp.app.card.model.command.ResponseException +import de.gematik.ti.erp.app.card.model.command.ResponseStatus +import de.gematik.ti.erp.app.card.model.exchange.retrieveCertificate +import de.gematik.ti.erp.app.card.model.exchange.signChallenge +import de.gematik.ti.erp.app.card.model.exchange.verifyPin +import de.gematik.ti.erp.app.cardwall.model.nfc.exchange.establishTrustedChannel import de.gematik.ti.erp.app.idp.usecase.IdpUseCase import de.gematik.ti.erp.app.nfc.model.card.NfcCardChannel import de.gematik.ti.erp.app.nfc.model.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.nfc.model.command.ResponseException -import de.gematik.ti.erp.app.nfc.model.command.ResponseStatus -import de.gematik.ti.erp.app.nfc.model.exchange.establishTrustedChannel -import de.gematik.ti.erp.app.nfc.model.exchange.retrieveCertificate -import de.gematik.ti.erp.app.nfc.model.exchange.signChallenge -import de.gematik.ti.erp.app.nfc.model.exchange.verifyPin import io.github.aakira.napier.Napier import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -96,7 +96,7 @@ enum class AuthenticationState { HealthCardCommunicationTrustedChannelEstablished, HealthCardCommunicationCertificateLoaded, HealthCardCommunicationFinished, - IDPCommunicationFinished, + IDPCommunicationFinished -> true else -> false } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/Hints.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/Hints.kt index 3c1348d5..c106bb71 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/Hints.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/Hints.kt @@ -82,7 +82,7 @@ data class HintCardProperties( val backgroundColor: Color, val contentColor: Color?, val border: BorderStroke?, - val elevation: Dp, + val elevation: Dp ) object HintCardDefaults { @@ -131,7 +131,7 @@ fun HintCard( backgroundColor = properties.backgroundColor, contentColor = properties.contentColor ?: contentColorFor(properties.backgroundColor), border = properties.border, - elevation = properties.elevation, + elevation = properties.elevation ) { if (properties.contentColor != null) { MaterialTheme( @@ -164,7 +164,6 @@ private fun HintCardInnerLayout( clip = false } ) { - image(innerPaddingLeft) Column( @@ -354,7 +353,7 @@ fun HintTextActionButton( @Composable fun HintTextLearnMoreButton( - uri: String = "https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten" + uri: String = "https://www.das-e-rezept-fuer-deutschland.de/faq" ) { val uriHandler = LocalUriHandler.current val offset = ButtonDefaults.TextButtonContentPadding.calculateLeftPadding(LocalLayoutDirection.current) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/strings/StringResource.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/strings/StringResource.kt index 89e028bc..55ad1552 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/strings/StringResource.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/strings/StringResource.kt @@ -3879,7 +3879,7 @@ public val stringsDe: Strings = Strings( 918 to Singular("Zahncreme"), 919 to Singular("Zahngel"), 920 to Singular("Zerbeißkapseln"), - 921 to Singular("Zahnpasta"), + 921 to Singular("Zahnpasta") ) ) @@ -4867,7 +4867,7 @@ public val stringsEn: Strings = Strings( 918 to stringsDe.kbvCodeDosageFormZcr, 919 to stringsDe.kbvCodeDosageFormZge, 920 to stringsDe.kbvCodeDosageFormZka, - 921 to stringsDe.kbvCodeDosageFormZpa, + 921 to stringsDe.kbvCodeDosageFormZpa ) ) @@ -5850,7 +5850,7 @@ public val stringsTr: Strings = Strings( 918 to stringsDe.kbvCodeDosageFormZcr, 919 to stringsDe.kbvCodeDosageFormZge, 920 to stringsDe.kbvCodeDosageFormZka, - 921 to stringsDe.kbvCodeDosageFormZpa, + 921 to stringsDe.kbvCodeDosageFormZpa ) ) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/theme/Theme.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/theme/Theme.kt index 64cf03ce..ac1ae39a 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/theme/Theme.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/common/theme/Theme.kt @@ -67,7 +67,7 @@ fun DesktopAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Compos fontWeight = FontWeight.W500 ), body1 = MaterialTheme.typography.body1.copy(fontFamily = fontFamily, lineHeight = 1.5.em), - body2 = MaterialTheme.typography.body2.copy(fontFamily = fontFamily, lineHeight = 1.5.em), + body2 = MaterialTheme.typography.body2.copy(fontFamily = fontFamily, lineHeight = 1.5.em) ), colors = Colors( primary = colors.primary600, diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/CommunicationRepository.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/CommunicationRepository.kt index 994eafbc..e9a5bc04 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/CommunicationRepository.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/CommunicationRepository.kt @@ -35,4 +35,6 @@ class CommunicationRepository( val communications = mapper.mapFhirBundleToSimpleCommunications(it) localDataSource.saveCommunications(communications) } + + suspend fun invalidate() = localDataSource.invalidate() } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/LocalDataSource.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/LocalDataSource.kt index 12e8d368..76749dd5 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/LocalDataSource.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/LocalDataSource.kt @@ -36,4 +36,8 @@ class LocalDataSource { fun loadCommunications(): Flow> { return communications } + + suspend fun invalidate() = lock.withLock { + communications.value = emptyList() + } } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/SimpleCommunication.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/SimpleCommunication.kt index 8c8b1019..242f450e 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/SimpleCommunication.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/repository/SimpleCommunication.kt @@ -47,9 +47,9 @@ data class CommunicationPayloadInbox( @SerialName("version") val version: String = "1", @SerialName("supplyOptionsType") val supplyOptionsType: CommunicationSupplyOption, @SerialName("info_text") val infoText: String, - @SerialName("url") val url: String?, - @SerialName("pickUpCodeHR") val pickUpCodeHR: String?, - @SerialName("pickUpCodeDMC") val pickUpCodeDMC: String? + @SerialName("url") val url: String? = null, + @SerialName("pickUpCodeHR") val pickUpCodeHR: String? = null, + @SerialName("pickUpCodeDMC") val pickUpCodeDMC: String? = null ) @Serializable diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationScreen.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationScreen.kt index 266c080a..524d9592 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationScreen.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationScreen.kt @@ -102,7 +102,7 @@ fun CommunicationScreen() { infoText = it.infoText, sender = it.sender, recipient = it.recipient, - sent = it.sent?.format(dtFormatter), + sent = it.sent?.format(dtFormatter) ) } } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationViewModel.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationViewModel.kt index 346211f6..23b7147c 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationViewModel.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/ui/CommunicationViewModel.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.map class CommunicationViewModel( private val dispatchersProvider: DispatchersProvider, - private val communicationUseCase: CommunicationUseCase, + private val communicationUseCase: CommunicationUseCase ) { val defaultState = CommunicationScreenData.State(emptyList()) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/usecase/CommunicationUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/usecase/CommunicationUseCase.kt index 12263640..93cf1c0b 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/usecase/CommunicationUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/communication/usecase/CommunicationUseCase.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.map class CommunicationUseCase( private val communicationRepository: CommunicationRepository, - private val prescriptionRepository: PrescriptionRepository, + private val prescriptionRepository: PrescriptionRepository ) { fun pharmacyCommunications(): Flow> = communicationRepository.communications().map { diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/fhir/FhirMapper.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/fhir/FhirMapper.kt index d84832a9..a7a23bc2 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/fhir/FhirMapper.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/fhir/FhirMapper.kt @@ -144,17 +144,6 @@ fun Bundle.extractKBVBundle(reference: String): Bundle.BundleEntryComponent? { return entry.find { it.resource.id.removePrefix("urn:uuid:") == cleanRefId } } -fun FhirTask.accessCode(): String { - identifier.forEach { - if (it.hasSystem()) { - if (it.system == "https://gematik.de/fhir/NamingSystem/AccessCode") { - return it.value - } - } - } - error("Access code not found!") -} - fun FhirTask.prescriptionId(): String? { identifier.forEach { if (it.hasSystem()) { @@ -217,7 +206,6 @@ class FhirMapper( SimpleTask( taskId = fhirTask.idElement.idPart, - accessCode = fhirTask.accessCode(), lastModified = fhirTask.lastModified.convertFhirDateToLocalDateTime(), organization = fhirOrganization.name ?: fhirPractitioner.nameFirstRep.nameAsSingleString, @@ -336,7 +324,7 @@ data class MedicationDetail( val text: String? = null, val dosageCode: String? = null, val normSizeCode: String? = null, - val uniqueIdentifier: String? = null, // PZN + val uniqueIdentifier: String? = null // PZN ) data class InsuranceCompanyDetail( @@ -384,12 +372,12 @@ fun FhirMedication.mapToUi(): MedicationDetail = MedicationDetail( text = this.code?.text, dosageCode = this.form?.coding?.find { it.system == "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM" }?.code, normSizeCode = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/normgroesse")?.value as? CodeType?)?.value, - uniqueIdentifier = this.code?.coding?.find { it.system == "http://fhir.de/CodeSystem/ifa/pzn" }?.code, + uniqueIdentifier = this.code?.coding?.find { it.system == "http://fhir.de/CodeSystem/ifa/pzn" }?.code ) fun FhirCoverage.mapToUi() = InsuranceCompanyDetail( name = this.payorFirstRep?.display, - statusCode = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/gkv/versichertenart")?.value as? Coding?)?.code, + statusCode = (this.getExtensionByUrl("http://fhir.de/StructureDefinition/gkv/versichertenart")?.value as? Coding?)?.code ) fun FhirOrganization.mapToUi() = OrganizationDetail( diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt index 8568a27c..7aa6ce3c 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/api/IdpService.kt @@ -67,7 +67,7 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun authorization( @Url url: String, @@ -77,7 +77,7 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun token( @Url url: String, @@ -85,18 +85,18 @@ interface IdpService { @Field("code") code: String, @Field("grant_type") grantType: String = "authorization_code", @Field("redirect_uri") redirectUri: String = REDIRECT_URI, - @Field("client_id") clientId: String = CLIENT_ID, + @Field("client_id") clientId: String = CLIENT_ID ): Response @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun ssoToken( @Url url: String, @Field("ssotoken") ssoToken: String, - @Field("unsigned_challenge") unsignedChallenge: String, + @Field("unsigned_challenge") unsignedChallenge: String ): Response companion object { diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt index a292035a..a4f2931d 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/repository/IdpRemoteDataSource.kt @@ -65,7 +65,9 @@ class IdpRemoteDataSource( suspend fun postChallenge(url: String, ssoToken: String, unsignedChallenge: String) = postToEndpointExpectingLocationRedirect { service.ssoToken(url, ssoToken, unsignedChallenge) } - private suspend inline fun postToEndpointExpectingLocationRedirect(crossinline call: suspend () -> Response) = + private suspend inline fun postToEndpointExpectingLocationRedirect( + crossinline call: suspend () -> Response + ) = safeApiCallRaw("error posting to redirecting endpoint") { val response = call() if (response.code() == HttpURLConnection.HTTP_MOVED_TEMP) { @@ -83,7 +85,7 @@ class IdpRemoteDataSource( suspend fun postToken( url: String, keyVerifier: String, - code: String, + code: String ) = safeApiCall("error posting for token") { service.token( url = url, diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt index a73228dc..eaca7643 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpBasicUseCase.kt @@ -69,7 +69,7 @@ enum class IdpScope { data class IdpChallengeFlowResult( val scope: IdpScope, - val challenge: IdpUnsignedChallenge, + val challenge: IdpUnsignedChallenge ) data class IdpAuthFlowResult( @@ -89,7 +89,7 @@ data class IdpInitialData( val state: IdpState, val nonce: IdpNonce, val codeVerifier: String, - val codeChallenge: String, + val codeChallenge: String ) data class IdpUnsignedChallenge( @@ -230,7 +230,7 @@ class IdpBasicUseCase( suspend fun challengeFlow( initialData: IdpInitialData, - scope: IdpScope, + scope: IdpScope ): IdpChallengeFlowResult { val (config, pukSigKey, _, state, nonce) = initialData val codeChallenge = initialData.codeChallenge @@ -345,7 +345,7 @@ class IdpBasicUseCase( suspend fun postSignedChallengeAndGetRedirect( url: String, codeChallenge: JsonWebEncryption, - state: IdpState, + state: IdpState ): URI { val redirect = URI(repository.postSignedChallenge(url, codeChallenge.compactSerialization).getOrThrow()) @@ -360,7 +360,7 @@ class IdpBasicUseCase( url: String, unsignedCodeChallenge: String, ssoToken: String, - state: IdpState, + state: IdpState ): URI { val redirect = URI(repository.postUnsignedChallengeWithSso(url, ssoToken, unsignedCodeChallenge).getOrThrow()) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt index e1872bec..7d76a370 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt @@ -51,7 +51,7 @@ class AltAuthenticationCryptoException(cause: Throwable) : IllegalStateException class IdpUseCase( private val repository: IdpRepository, - private val basicUseCase: IdpBasicUseCase, + private val basicUseCase: IdpBasicUseCase ) { private val lock = Mutex() diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardScreen.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardScreen.kt index 707979ea..7c25a355 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardScreen.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardScreen.kt @@ -173,14 +173,14 @@ fun LoginWithHealthCard( val isPersonalIdentificationNumberValid = personalIdentificationNumber.matches("""^\d{6,8}$""".toRegex()) val maxPages = - if (privacyAndTermsToggled) - if (cardReaderPresent) - if (isCardAccessNumberValid) + if (privacyAndTermsToggled) { + if (cardReaderPresent) { + if (isCardAccessNumberValid) { if (isPersonalIdentificationNumberValid) 5 else 4 - else 3 - else 2 - else 1 + } else 3 + } else 2 + } else 1 val scope = rememberCoroutineScope() @@ -340,20 +340,19 @@ private fun NavigationForward( } val interactionSource = remember { MutableInteractionSource() } + rememberRipple() Surface( - modifier = modifier, - shape = CircleShape, - color = backgroundColor, - contentColor = contentColor, - elevation = if (enabled) FloatingActionButtonDefaults.elevation().elevation(interactionSource).value else 0.dp, - enabled = enabled, onClick = { if (enabled) { onClick() } }, - role = Role.Button, - indication = rememberRipple() + modifier = modifier, + enabled = enabled, + shape = CircleShape, + color = backgroundColor, + contentColor = contentColor, + elevation = if (enabled) FloatingActionButtonDefaults.elevation().elevation(interactionSource).value else 0.dp ) { CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) { ProvideTextStyle(MaterialTheme.typography.button) { @@ -383,17 +382,15 @@ private fun NavigationBack( content: @Composable RowScope.() -> Unit ) { Surface( - modifier = modifier, - shape = CircleShape, - color = Color.Unspecified, - contentColor = AppTheme.colors.neutral600, onClick = { if (enabled) { onClick() } }, - role = Role.Button, - indication = rememberRipple() + modifier = modifier, + shape = CircleShape, + color = Color.Unspecified, + contentColor = AppTheme.colors.neutral600 ) { ProvideTextStyle(MaterialTheme.typography.button) { Box( @@ -529,7 +526,7 @@ private fun Toggle( modifier = Modifier .fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { Text( text = text, @@ -566,11 +563,13 @@ private fun Toggle( ) { when (it) { false -> Icon( - Icons.Rounded.RadioButtonUnchecked, null, - tint = AppTheme.colors.neutral400, + Icons.Rounded.RadioButtonUnchecked, + null, + tint = AppTheme.colors.neutral400 ) true -> Icon( - Icons.Rounded.CheckCircle, null, + Icons.Rounded.CheckCircle, + null, tint = AppTheme.colors.primary600 ) } @@ -620,7 +619,8 @@ private fun AwaitCardReader( ReaderState.Found -> App.strings.desktopLoginPageReaderFoundHealthcard() ReaderState.Error -> App.strings.desktopLoginPageReaderError() }, - style = MaterialTheme.typography.subtitle1, textAlign = TextAlign.Center + style = MaterialTheme.typography.subtitle1, + textAlign = TextAlign.Center ) } } @@ -795,7 +795,8 @@ private fun EnterCardAccessNumber( .shadow(1.dp, shape) .then(if ((can.length == it || it == 5 && can.length == 6) && isFocussed) borderModifier else Modifier) .background( - color = backgroundColor, shape, + color = backgroundColor, + shape ) .graphicsLayer { clip = false diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardViewModel.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardViewModel.kt index 9e5926ee..43a77901 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardViewModel.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/login/ui/LoginWithHealthCardViewModel.kt @@ -63,7 +63,8 @@ class LoginWithHealthCardViewModel( fun authenticate(can: String, pin: String): Flow = authenticationUseCase.authenticateWithHealthCard( - can = can, pin = pin, + can = can, + pin = pin, flow { var reader: CardReader? do { diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/LoggedInScreenScaffold.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/LoggedInScreenScaffold.kt index c0c8d582..6487b817 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/LoggedInScreenScaffold.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/LoggedInScreenScaffold.kt @@ -93,12 +93,12 @@ fun LoggedInScreen( navigation: Navigation ) { Scaffold( - backgroundColor = MaterialTheme.colors.surface, + backgroundColor = MaterialTheme.colors.surface ) { Row(Modifier.fillMaxSize()) { SideBar( mainViewModel = mainViewModel, - navigation = navigation, + navigation = navigation ) VerticalDivider() Column { @@ -125,7 +125,7 @@ fun LoggedInScreen( @Composable private fun DarkModeToggle( toggled: Boolean, - onToggle: (Boolean) -> Unit, + onToggle: (Boolean) -> Unit ) { val handleOffset by animateDpAsState(if (toggled) 32.dp - 20.dp else 0.dp) Box( @@ -189,7 +189,7 @@ private fun TopBar( Text(title, style = MaterialTheme.typography.h6) Spacer(Modifier.weight(1f)) Logo( - Modifier.height(24.dp), + Modifier.height(24.dp) ) } } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/MainScreen.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/MainScreen.kt index d0a6d7c9..30b364fc 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/MainScreen.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/main/ui/MainScreen.kt @@ -139,7 +139,7 @@ fun MainScreen( private fun DataMatrixCode( navigation: Navigation, taskId: String, - accessCode: String, + accessCode: String ) { ClosablePopupScaffold(onClose = { navigation.back() }) { Box(Modifier.fillMaxSize()) { @@ -270,7 +270,7 @@ fun InitialWelcomeScreen( ) { Column(Modifier.fillMaxSize()) { Logo( - Modifier.padding(top = 48.dp, start = 96.dp).height(32.dp), + Modifier.padding(top = 48.dp, start = 96.dp).height(32.dp) ) Spacer(Modifier.weight(1f)) Column(Modifier.align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { @@ -292,7 +292,8 @@ fun InitialWelcomeScreen( } Spacer(Modifier.weight(1f)) Image( - painterResource("images/crew.webp"), null, + painterResource("images/crew.webp"), + null, alignment = Alignment.BottomCenter, modifier = Modifier .fillMaxWidth() diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/CardUtilities.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/CardUtilities.kt deleted file mode 100644 index d6fff49a..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/CardUtilities.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model - -import org.bouncycastle.asn1.ASN1InputStream -import org.bouncycastle.asn1.ASN1Object -import org.bouncycastle.asn1.DLApplicationSpecific -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.bouncycastle.math.ec.ECCurve -import org.bouncycastle.math.ec.ECPoint -import java.math.BigInteger -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate - -/** - * Utility class for card functions - */ -object CardUtilities { - private const val UNCOMPRESSEDPOINTVALUE = 0x04 - - /** - * Decodes an ECPoint from byte array. Prime field p is taken from the passed curve - * The first byte must contain the value 0x04 (uncompressed point). - * - * @param byteArray Byte array of the form {0x04 || x-bytes [] || y byte []} - * @param curve The curve on which the point should lie. - * @return EC point generated from input data - */ - fun byteArrayToECPoint(byteArray: ByteArray, curve: ECCurve): ECPoint { - return if (byteArray[0] != UNCOMPRESSEDPOINTVALUE.toByte()) { - throw IllegalArgumentException("Found no uncompressed point!") - } else { - val x = ByteArray((byteArray.size - 1) / 2) - val y = ByteArray((byteArray.size - 1) / 2) - - System.arraycopy(byteArray, 1, x, 0, (byteArray.size - 1) / 2) - System.arraycopy( - byteArray, 1 + (byteArray.size - 1) / 2, y, 0, - (byteArray.size - 1) / 2 - ) - curve.createPoint(BigInteger(1, x), BigInteger(1, y)) - } - } - - /** - * Encodes an ASN1 KeyObject - */ - fun extractKeyObjectEncoded(asn1Input: ByteArray): ByteArray = - ASN1InputStream(asn1Input).use { asn1InputStream -> - val seq = asn1InputStream.readObject() as DLApplicationSpecific - val seqObj: ASN1Object = seq.getObject() - seqObj.encoded.copyOfRange(2, seqObj.encoded.size) - } -} - -fun ByteArray.toX509Certificate() = - CertificateFactory.getInstance("X.509", BouncyCastleProvider()).let { - it.generateCertificate(this.inputStream()) as X509Certificate - } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/EncryptedPinFormat2.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/EncryptedPinFormat2.kt deleted file mode 100644 index 40f99e66..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/EncryptedPinFormat2.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.card - -/** - * The format 2 PIN block has been specified for use with IC cards. The format 2 PIN block shall only be used in - * an offline environment and shall not be used for online PIN verification. This PIN block is constructed by - * concatenation of two fields: the plain text PIN field and the filler field. - * - * @see "ISO 9564-1" - */ - -private const val NIBBLE_SIZE = 4 -private const val MIN_PIN_LEN = 4 // specSpec_COS#N008.000 -private const val MAX_PIN_LEN = 12 // specSpec_COS#N008.000 -private const val FORMAT_PIN_2_ID = 0x02 shl NIBBLE_SIZE // specSpec_COS#N008.100 -private const val FORMAT2_PIN_SIZE = 8 -private const val FORMAT2_PIN_FILLER = 0x0F -private const val MIN_DIGIT = 0 // specSpec_COS#N008.000 -private const val MAX_DIGIT = 9 // specSpec_COS#N008.000 -private const val STRING_INT_OFFSET = 48 - -class EncryptedPinFormat2(pin: String) { - val bytes: ByteArray - get() = field.copyOf() - - init { - val intPin = pin.map { it.toInt() - STRING_INT_OFFSET } - - require(intPin.size >= MIN_PIN_LEN) { "PIN length is too short, min length is " + MIN_PIN_LEN + ", but was " + intPin.size } - require(intPin.size <= MAX_PIN_LEN) { "PIN length is too long, max length is " + MAX_PIN_LEN + ", but was " + intPin.size } - - intPin.forEach { - require(it in MIN_DIGIT..MAX_DIGIT) { "PIN digit value is out of range of a decimal digit: ${(it + STRING_INT_OFFSET).toChar()}" } - } - - val format2 = IntArray(FORMAT2_PIN_SIZE) // specSpec_COS#N008.100 - format2[0] = FORMAT_PIN_2_ID + intPin.size - for (i in intPin.indices) { - format2[1 + i / 2] += if ((i + 2) % 2 == 0) { - intPin[i] shl NIBBLE_SIZE - } else { - intPin[i] - } - } - for (i in intPin.size until 2 * FORMAT2_PIN_SIZE - 2) { - format2[1 + i / 2] += if (i % 2 == 0) { - FORMAT2_PIN_FILLER shl NIBBLE_SIZE - } else { - FORMAT2_PIN_FILLER - } - } - - val b = ByteArray(FORMAT2_PIN_SIZE) - for (i in b.indices) { - b[i] = format2[i].toByte() - } - bytes = b - } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardChannel.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardChannel.kt index ec206a06..53a33e8e 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardChannel.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardChannel.kt @@ -18,13 +18,14 @@ package de.gematik.ti.erp.app.nfc.model.card -import de.gematik.ti.erp.app.nfc.model.command.CommandApdu -import de.gematik.ti.erp.app.nfc.model.command.ResponseApdu +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import java.io.Closeable class NfcCardChannel internal constructor( override val isExtendedLengthSupported: Boolean, - private val nfcHealthCard: NfcHealthCard, + private val nfcHealthCard: NfcHealthCard ) : ICardChannel, Closeable { override val card: NfcHealthCard get() = nfcHealthCard diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardSecureChannel.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardSecureChannel.kt index 0312d913..7a594781 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardSecureChannel.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcCardSecureChannel.kt @@ -18,8 +18,12 @@ package de.gematik.ti.erp.app.nfc.model.card -import de.gematik.ti.erp.app.nfc.model.command.CommandApdu -import de.gematik.ti.erp.app.nfc.model.command.ResponseApdu +import de.gematik.ti.erp.app.card.model.card.ICardChannel +import de.gematik.ti.erp.app.card.model.card.IHealthCard +import de.gematik.ti.erp.app.card.model.card.PaceKey +import de.gematik.ti.erp.app.card.model.card.SecureMessaging +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import io.github.aakira.napier.Napier class NfcCardSecureChannel internal constructor( @@ -29,7 +33,7 @@ class NfcCardSecureChannel internal constructor( ) : ICardChannel { private var secureMessaging = SecureMessaging(paceKey) - override val card: NfcHealthCard + override val card: IHealthCard get() = nfcHealthCard override val maxTransceiveLength = 1024 diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcHealthCard.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcHealthCard.kt index edd125cd..e39a4ca9 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcHealthCard.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/NfcHealthCard.kt @@ -18,18 +18,18 @@ package de.gematik.ti.erp.app.nfc.model.card -import de.gematik.ti.erp.app.nfc.model.command.CommandApdu -import de.gematik.ti.erp.app.nfc.model.command.ResponseApdu +import de.gematik.ti.erp.app.card.model.card.IHealthCard +import de.gematik.ti.erp.app.card.model.command.CommandApdu +import de.gematik.ti.erp.app.card.model.command.ResponseApdu import de.gematik.ti.erp.app.smartcard.Card import de.gematik.ti.erp.app.smartcard.CardReader import java.nio.ByteBuffer -class NfcHealthCard private constructor(val card: Card) { +class NfcHealthCard private constructor(val card: Card) : IHealthCard { private val buffer = ByteBuffer.allocate(1024) - fun transmit(apduCommand: CommandApdu): ResponseApdu { + override fun transmit(apduCommand: CommandApdu): ResponseApdu { buffer.clear() - val n = card.transmit(ByteBuffer.wrap(apduCommand.bytes), buffer) return ResponseApdu(buffer.array().copyOfRange(0, n)) } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/Password.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/Password.kt deleted file mode 100644 index d5a81672..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/Password.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.card - -/** - * A password can be a regular password or multireference password - * - * * A "regular password" is used to store a secret, which is usually only known to one cardholder. The COS will allow certain services only if this secret has been successfully presented as part of a user verification. The need for user verification can be turned on (enable) or turned off (disable). - * * A multireference password allows the use of a secret, which is stored as an at-tributary in a regular password (see (N015.200)), but under conditions that deviate from those of the regular password. - * - * @see "gemSpec_COS 'Spezifikation des Card Operating System'" - */ - -private const val MIN_PWD_ID = 0 -private const val MAX_PWD_ID = 31 - -class Password(val pwdId: Int) : ICardKeyReference { - init { - require(!(pwdId < MIN_PWD_ID || pwdId > MAX_PWD_ID)) { - // gemSpec_COS#N015.000 - "Password ID out of range [$MIN_PWD_ID,$MAX_PWD_ID]" - } - } - - // gemSpec_COS#N072.800 - override fun calculateKeyReference(dfSpecific: Boolean): Int = - pwdId + if (dfSpecific) { - ICardKeyReference.DF_SPECIFIC_PWD_MARKER - } else { - 0 - } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/SecureMessaging.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/SecureMessaging.kt deleted file mode 100644 index 2f475a4b..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/card/SecureMessaging.kt +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.card - -import de.gematik.ti.erp.app.BCProvider -import de.gematik.ti.erp.app.nfc.model.command.CommandApdu -import de.gematik.ti.erp.app.nfc.model.command.EXPECTED_LENGTH_WILDCARD_EXTENDED -import de.gematik.ti.erp.app.nfc.model.command.EXPECTED_LENGTH_WILDCARD_SHORT -import de.gematik.ti.erp.app.nfc.model.command.ResponseApdu -import de.gematik.ti.erp.app.nfc.model.tagobjects.DataObject -import de.gematik.ti.erp.app.nfc.model.tagobjects.LengthObject -import de.gematik.ti.erp.app.nfc.model.tagobjects.MacObject -import de.gematik.ti.erp.app.nfc.model.tagobjects.StatusObject -import de.gematik.ti.erp.app.utils.Bytes.padData -import de.gematik.ti.erp.app.utils.Bytes.unPadData -import io.github.aakira.napier.Napier -import org.bouncycastle.asn1.DERTaggedObject -import org.bouncycastle.util.encoders.Hex -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.math.BigInteger -import java.security.Key -import java.security.spec.AlgorithmParameterSpec -import javax.crypto.Cipher -import javax.crypto.Cipher.DECRYPT_MODE -import javax.crypto.Cipher.ENCRYPT_MODE -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.experimental.or - -private const val SECURE_MESSAGING_COMMAND = 0x0C.toByte() -private val PADDING_INDICATOR = byteArrayOf(0x01.toByte()) -private const val BLOCK_SIZE = 16 -private const val MAC_SIZE = 8 -private const val STATUS_SIZE: Int = 0x02 -private const val MIN_RESPONSE_SIZE = 12 -private const val HEADER_SIZE = 4 - -private const val DO_81_TAG = 0x81 -private const val DO_87_TAG = 0x87 -private const val DO_99_TAG = 0x99 -private const val DO_8E_TAG = 0x8E -private const val LENGTH_TAG = 0x80 -private const val BYTE_MASK = 0x0F -private const val MALFORMED_SECURE_MESSAGING_APDU = "Malformed Secure Messaging APDU" - -class SecureMessaging(private val paceKey: PaceKey) { - private val secureMessagingSSC: ByteArray = ByteArray(BLOCK_SIZE) - - private fun incrementSSC() { - for (i in secureMessagingSSC.indices.reversed()) { - secureMessagingSSC[i]++ - if (secureMessagingSSC[i] != 0.toByte()) { - break - } - } - } - - /** - * Encrypts a plain APDU - * - * @param commandApdu plain Command APDU - * @return encrypted Command APDU - */ - fun encrypt(commandApdu: CommandApdu): CommandApdu { - val apduToEncrypt = commandApdu.bytes // copy - - Napier.d("Plain APDU: ${Hex.toHexString(apduToEncrypt)}") - - incrementSSC() - - require(apduToEncrypt.size >= HEADER_SIZE) { "APDU must be at least 4 bytes long" } - - val header = apduToEncrypt.copyOfRange(0, HEADER_SIZE) - setSecureMessagingCommand(header) - - val commandDataOutput = ByteArrayOutputStream() - - apduToEncrypt.copyOfRange( - commandApdu.dataOffset, - commandApdu.dataOffset + commandApdu.rawNc - ) - .takeIf { it.isNotEmpty() } - ?.let { - var data = it - data = padData(data, BLOCK_SIZE) - data = encryptData(data) - data = PADDING_INDICATOR + data - - // write encrypted data to output - DataObject(data).taggedObject.encodeTo(commandDataOutput) - } - - val le = commandApdu.rawNe?.also { - // write length object to output - LengthObject(it).taggedObject.encodeTo(commandDataOutput) - } ?: -1 - - Napier.d("build encrypted command") - - val commandMacObject = MacObject(header, commandDataOutput, paceKey.mac, secureMessagingSSC) - return createEncryptedCommand( - le = le, - data = commandDataOutput, - do8E = commandMacObject.taggedObject, - header = header - ) - } - - private fun setSecureMessagingCommand(header: ByteArray) { - require(header[0] != (header[0] or SECURE_MESSAGING_COMMAND)) { MALFORMED_SECURE_MESSAGING_APDU } - header[0] = (header[0] or SECURE_MESSAGING_COMMAND) - } - - private fun encryptData(paddedData: ByteArray) = - getCipher(ENCRYPT_MODE).doFinal(paddedData) - - private fun createEncryptedCommand( - le: Int, - data: ByteArrayOutputStream, - do8E: DERTaggedObject, - header: ByteArray, - ): CommandApdu { - - val tempData = data - // write do8E to output - do8E.encodeTo(data) - - val ne = if (tempData.size() < 1 && le == -1) { - EXPECTED_LENGTH_WILDCARD_SHORT - } else if (tempData.size() < 1 && le > -1) { - EXPECTED_LENGTH_WILDCARD_EXTENDED - } else if (tempData.size() > 0 && le < 0) { - if (data.size() <= 255) { - EXPECTED_LENGTH_WILDCARD_SHORT - } else { - EXPECTED_LENGTH_WILDCARD_EXTENDED - } - } else EXPECTED_LENGTH_WILDCARD_EXTENDED - - return CommandApdu.ofOptions( - cla = header[0].toInt() and 0xFF, - ins = header[1].toInt() and 0xFF, - p1 = header[2].toInt() and 0xFF, - p2 = header[3].toInt() and 0xFF, - data = data.toByteArray(), - ne = ne - ) - } - - /** - * Decrypts an encrypted Response APDU - */ - fun decrypt(responseApdu: ResponseApdu): ResponseApdu { - val apduResponseBytes = responseApdu.bytes // copy - val statusBytes = ByteArray(2) - val macBytes = ByteArray(MAC_SIZE) - - Napier.d("Encrypted Response APDU: ${Hex.toHexString(apduResponseBytes)}") - - val responseDataOutput = ByteArrayOutputStream() - - require(apduResponseBytes.size >= MIN_RESPONSE_SIZE) { MALFORMED_SECURE_MESSAGING_APDU } - - incrementSSC() - - val dataObject = getResponseObjects(statusBytes, macBytes, apduResponseBytes) - // write data object to output - dataObject?.taggedObject?.encodeTo(responseDataOutput) - - // write status object to output - StatusObject(statusBytes).taggedObject.encodeTo(responseDataOutput) - - val responseMacObject = MacObject( - commandOutput = responseDataOutput, - kMac = paceKey.mac, - ssc = secureMessagingSSC - ) - checkMac(responseMacObject.mac, macBytes) - - return createDecryptedResponse(statusBytes, dataObject) - } - - private fun checkMac(mac: ByteArray, macObject: ByteArray) { - require(mac.contentEquals(macObject)) { "Secure Messaging MAC verification failed" } - } - - private fun getResponseObjects( - statusBytes: ByteArray, - macBytes: ByteArray, - apduResponseBytes: ByteArray - ): DataObject? { - val inputStream = ByteArrayInputStream(apduResponseBytes) - - var dataTag = 0x0.toByte() - var data: ByteArray? = null - - var tag = inputStream.read().toByte() - if (tag == DO_81_TAG.toByte() || tag == DO_87_TAG.toByte()) { - dataTag = tag - - var size = inputStream.read() - if (size > LENGTH_TAG) { - val sizeBytes = ByteArray(size and BYTE_MASK) - - inputStream.readAndCheckExpectedLength(sizeBytes, sizeBytes.size) - - size = BigInteger(1, sizeBytes).toInt() - } - - data = ByteArray(size) - inputStream.readAndCheckExpectedLength(data, data.size) - - tag = inputStream.read().toByte() - } - - require(tag == DO_99_TAG.toByte()) { MALFORMED_SECURE_MESSAGING_APDU } - - if (inputStream.read() == STATUS_SIZE) { - inputStream.readAndCheckExpectedLength(statusBytes, STATUS_SIZE) - - tag = inputStream.read().toByte() - } - - require(tag == DO_8E_TAG.toByte()) { MALFORMED_SECURE_MESSAGING_APDU } - - if (inputStream.read() == MAC_SIZE) { - inputStream.readAndCheckExpectedLength(macBytes, MAC_SIZE) - } - - require(inputStream.available() == 2) { MALFORMED_SECURE_MESSAGING_APDU } - - return data?.let { - DataObject(it, dataTag) - } - } - - private fun createDecryptedResponse( - statusBytes: ByteArray, - dataObject: DataObject? - ): ResponseApdu { - val outputStream = ByteArrayOutputStream() - if (dataObject != null) { - if (dataObject.tag == DO_87_TAG.toByte()) { - val dataDecrypted = removePaddingIndicator(dataObject.data).let { - getCipher(DECRYPT_MODE).doFinal(it) - } - outputStream.write(unPadData(dataDecrypted)) - - Napier.d("data decrypted: ${Hex.toHexString(dataDecrypted)}") - } else { - outputStream.write(dataObject.data) - } - } - outputStream.write(statusBytes) - return ResponseApdu(outputStream.toByteArray()) - } - - private fun removePaddingIndicator(dataBytes: ByteArray): ByteArray = - dataBytes.copyOfRange(1, dataBytes.size) - - private fun getCipher(mode: Int): Cipher = - Cipher.getInstance("AES/CBC/NoPadding", BCProvider).apply { - val key: Key = SecretKeySpec(paceKey.enc, "AES") - val iv = createCipherIV() - val aps: AlgorithmParameterSpec = IvParameterSpec(iv) - - init(mode, key, aps) - } - - private fun createCipherIV(): ByteArray = - // ECB instead of CBC on purpose. COS doesn't support CBC for this. - Cipher.getInstance("AES/ECB/NoPadding", BCProvider).let { - val key: Key = SecretKeySpec(paceKey.enc, "AES") - it.init(ENCRYPT_MODE, key) - it.doFinal(secureMessagingSSC) - } -} - -private fun InputStream.readAndCheckExpectedLength(b: ByteArray, expected: Int) { - val l = this.read(b, 0, expected) - require(l == expected) { MALFORMED_SECURE_MESSAGING_APDU } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ResponseStatus.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ResponseStatus.kt deleted file mode 100644 index ff40e6d2..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/command/ResponseStatus.kt +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.command - -val generalAuthenticateStatus = mapOf( - 0x0000 to ResponseStatus.UNKNOWN_STATUS, - 0x9000 to ResponseStatus.SUCCESS, - 0x6300 to ResponseStatus.AUTHENTICATION_FAILURE, - 0x6400 to ResponseStatus.PARAMETER_MISMATCH, - 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, - 0x6983 to ResponseStatus.KEY_EXPIRED, - 0x6985 to ResponseStatus.NO_KEY_REFERENCE, - 0x6A80 to ResponseStatus.NUMBER_PRECONDITION_WRONG, - 0x6A81 to ResponseStatus.UNSUPPORTED_FUNCTION, - 0x6A88 to ResponseStatus.KEY_NOT_FOUND -) - -val pinStatus = mapOf( - 0x9000 to ResponseStatus.SUCCESS, - 0x62C1 to ResponseStatus.TRANSPORT_STATUS_TRANSPORT_PIN, - 0x62C7 to ResponseStatus.TRANSPORT_STATUS_EMPTY_PIN, - 0x62D0 to ResponseStatus.PASSWORD_DISABLED, - 0x63C0 to ResponseStatus.RETRY_COUNTER_COUNT_00, - 0x63C1 to ResponseStatus.RETRY_COUNTER_COUNT_01, - 0x63C2 to ResponseStatus.RETRY_COUNTER_COUNT_02, - 0x63C3 to ResponseStatus.RETRY_COUNTER_COUNT_03, - 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, - 0x6988 to ResponseStatus.PASSWORD_NOT_FOUND -) - -val manageSecurityEnvironmentStatus = mapOf( - 0x9000 to ResponseStatus.SUCCESS, - 0x6A81 to ResponseStatus.UNSUPPORTED_FUNCTION, - 0x6A88 to ResponseStatus.KEY_NOT_FOUND -) - -val psoComputeDigitalSignatureStatus = mapOf( - 0x9000 to ResponseStatus.SUCCESS, - 0x6400 to ResponseStatus.KEY_INVALID, - 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, - 0x6985 to ResponseStatus.NO_KEY_REFERENCE, - 0x6A81 to ResponseStatus.UNSUPPORTED_FUNCTION, - 0x6A88 to ResponseStatus.KEY_NOT_FOUND -) - -val readStatus = mapOf( - 0x9000 to ResponseStatus.SUCCESS, - 0x6281 to ResponseStatus.CORRUPT_DATA_WARNING, - 0x6282 to ResponseStatus.END_OF_FILE_WARNING, - 0x6981 to ResponseStatus.WRONG_FILE_TYPE, - 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, - 0x6986 to ResponseStatus.NO_CURRENT_EF, - 0x6A82 to ResponseStatus.FILE_NOT_FOUND, - 0x6B00 to ResponseStatus.OFFSET_TOO_BIG -) - -val selectStatus = mapOf( - 0x9000 to ResponseStatus.SUCCESS, - 0x6283 to ResponseStatus.FILE_DEACTIVATED, - 0x6285 to ResponseStatus.FILE_TERMINATED, - 0x6A82 to ResponseStatus.FILE_NOT_FOUND, - 0x6D00 to ResponseStatus.INSTRUCTION_NOT_SUPPORTED, -) - -val verifyStatus = mapOf( - 0x9000 to ResponseStatus.SUCCESS, - 0x63C0 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_00, - 0x63C1 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, - 0x63C2 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_02, - 0x63C3 to ResponseStatus.WRONG_SECRET_WARNING_COUNT_03, - 0x6581 to ResponseStatus.MEMORY_FAILURE, - 0x6982 to ResponseStatus.SECURITY_STATUS_NOT_SATISFIED, - 0x6983 to ResponseStatus.PASSWORD_BLOCKED, - 0x6985 to ResponseStatus.PASSWORD_NOT_USABLE, - 0x6988 to ResponseStatus.PASSWORD_NOT_FOUND, -) - -/** - * All response status codes - * @see "gemSpec_COS_16.2" - */ -enum class ResponseStatus { - // spec: gemSpec_COS_16.2 - SUCCESS, - UNKNOWN_EXCEPTION, - UNKNOWN_STATUS, - DATA_TRUNCATED, - CORRUPT_DATA_WARNING, - END_OF_FILE_WARNING, - END_OF_RECORD_WARNING, - UNSUCCESSFUL_SEARCH, - FILE_DEACTIVATED, - FILE_TERMINATED, - RECORD_DEACTIVATED, - TRANSPORT_STATUS_TRANSPORT_PIN, - TRANSPORT_STATUS_EMPTY_PIN, - PASSWORD_DISABLED, - AUTHENTICATION_FAILURE, - NO_AUTHENTICATION, - RETRY_COUNTER_COUNT_00, - RETRY_COUNTER_COUNT_01, - RETRY_COUNTER_COUNT_02, - RETRY_COUNTER_COUNT_03, - RETRY_COUNTER_COUNT_04, - RETRY_COUNTER_COUNT_05, - RETRY_COUNTER_COUNT_06, - RETRY_COUNTER_COUNT_07, - RETRY_COUNTER_COUNT_08, - RETRY_COUNTER_COUNT_09, - RETRY_COUNTER_COUNT_10, - RETRY_COUNTER_COUNT_11, - RETRY_COUNTER_COUNT_12, - RETRY_COUNTER_COUNT_13, - RETRY_COUNTER_COUNT_14, - RETRY_COUNTER_COUNT_15, - UPDATE_RETRY_WARNING_COUNT_00, - UPDATE_RETRY_WARNING_COUNT_01, - UPDATE_RETRY_WARNING_COUNT_02, - UPDATE_RETRY_WARNING_COUNT_03, - UPDATE_RETRY_WARNING_COUNT_04, - UPDATE_RETRY_WARNING_COUNT_05, - UPDATE_RETRY_WARNING_COUNT_06, - UPDATE_RETRY_WARNING_COUNT_07, - UPDATE_RETRY_WARNING_COUNT_08, - UPDATE_RETRY_WARNING_COUNT_09, - UPDATE_RETRY_WARNING_COUNT_10, - UPDATE_RETRY_WARNING_COUNT_11, - UPDATE_RETRY_WARNING_COUNT_12, - UPDATE_RETRY_WARNING_COUNT_13, - UPDATE_RETRY_WARNING_COUNT_14, - UPDATE_RETRY_WARNING_COUNT_15, - WRONG_SECRET_WARNING_COUNT_00, - WRONG_SECRET_WARNING_COUNT_01, - WRONG_SECRET_WARNING_COUNT_02, - WRONG_SECRET_WARNING_COUNT_03, - WRONG_SECRET_WARNING_COUNT_04, - WRONG_SECRET_WARNING_COUNT_05, - WRONG_SECRET_WARNING_COUNT_06, - WRONG_SECRET_WARNING_COUNT_07, - WRONG_SECRET_WARNING_COUNT_08, - WRONG_SECRET_WARNING_COUNT_09, - WRONG_SECRET_WARNING_COUNT_10, - WRONG_SECRET_WARNING_COUNT_11, - WRONG_SECRET_WARNING_COUNT_12, - WRONG_SECRET_WARNING_COUNT_13, - WRONG_SECRET_WARNING_COUNT_14, - WRONG_SECRET_WARNING_COUNT_15, - ENCIPHER_ERROR, - KEY_INVALID, - OBJECT_TERMINATED, - PARAMETER_MISMATCH, - MEMORY_FAILURE, - WRONG_RECORD_LENGTH, - CHANNEL_CLOSED, - NO_MORE_CHANNELS_AVAILABLE, - VOLATILE_KEY_WITHOUT_LCS, - WRONG_FILE_TYPE, - SECURITY_STATUS_NOT_SATISFIED, - COMMAND_BLOCKED, - KEY_EXPIRED, - PASSWORD_BLOCKED, - KEY_ALREADY_PRESENT, - NO_KEY_REFERENCE, - NO_PRK_REFERENCE, - NO_PUK_REFERENCE, - NO_RANDOM, - NO_RECORD_LIFE_CYCLE_STATUS, - PASSWORD_NOT_USABLE, - WRONG_RANDOM_LENGTH, - WRONG_RANDOM_OR_NO_KEY_REFERENCE, - WRONG_PASSWORD_LENGTH, - NO_CURRENT_EF, - INCORRECT_SM_DO, - NEW_FILE_SIZE_WRONG, - NUMBER_PRECONDITION_WRONG, - NUMBER_SCENARIO_WRONG, - VERIFICATION_ERROR, - WRONG_CIPHER_TEXT, - WRONG_TOKEN, - UNSUPPORTED_FUNCTION, - FILE_NOT_FOUND, - RECORD_NOT_FOUND, - DATA_TOO_BIG, - FULL_RECORD_LIST, - MESSAGE_TOO_LONG, - OUT_OF_MEMORY, - INCONSISTENT_KEY_REFERENCE, - WRONG_KEY_REFERENCE, - KEY_NOT_FOUND, - KEY_OR_PRK_NOT_FOUND, - PASSWORD_NOT_FOUND, - PRK_NOT_FOUND, - PUK_NOT_FOUND, - DUPLICATED_OBJECTS, - DF_NAME_EXISTS, - OFFSET_TOO_BIG, - INSTRUCTION_NOT_SUPPORTED; -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/PinExchange.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/PinExchange.kt deleted file mode 100644 index b68cedbf..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/PinExchange.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.exchange - -import de.gematik.ti.erp.app.nfc.model.card.EncryptedPinFormat2 -import de.gematik.ti.erp.app.nfc.model.card.NfcCardSecureChannel -import de.gematik.ti.erp.app.nfc.model.card.Password -import de.gematik.ti.erp.app.nfc.model.cardobjects.Mf -import de.gematik.ti.erp.app.nfc.model.command.HealthCardCommand -import de.gematik.ti.erp.app.nfc.model.command.ResponseStatus -import de.gematik.ti.erp.app.nfc.model.command.executeSuccessfulOn -import de.gematik.ti.erp.app.nfc.model.command.select -import de.gematik.ti.erp.app.nfc.model.command.verifyPin -import io.github.aakira.napier.Napier - -fun NfcCardSecureChannel.verifyPin(pin: String): ResponseStatus { - HealthCardCommand.select(selectParentElseRoot = false, readFirst = false) - .executeSuccessfulOn(this) - - val password = Password(Mf.MrPinHome.PWID) - - Napier.d("Verify pin") - - val response = - HealthCardCommand.verifyPin(password, false, EncryptedPinFormat2(pin)) - .executeOn(this) - - require( - when (response.status) { - ResponseStatus.SUCCESS, - ResponseStatus.WRONG_SECRET_WARNING_COUNT_01, - ResponseStatus.WRONG_SECRET_WARNING_COUNT_02, - ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> - true - else -> - false - } - ) { "Verify pin command failed with status: ${response.status}" } - - Napier.d("Pin verified with status ${response.status}") - - return response.status -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/TrustedChannelPaceKeyExchange.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/TrustedChannelPaceKeyExchange.kt deleted file mode 100644 index 5dfe378d..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/exchange/TrustedChannelPaceKeyExchange.kt +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.exchange - -import de.gematik.ti.erp.app.nfc.model.CardUtilities.byteArrayToECPoint -import de.gematik.ti.erp.app.nfc.model.CardUtilities.extractKeyObjectEncoded -import de.gematik.ti.erp.app.nfc.model.card.CardKey -import de.gematik.ti.erp.app.nfc.model.card.HealthCardVersion2 -import de.gematik.ti.erp.app.nfc.model.card.NfcCardChannel -import de.gematik.ti.erp.app.nfc.model.card.PaceKey -import de.gematik.ti.erp.app.nfc.model.card.isEGK21 -import de.gematik.ti.erp.app.nfc.model.cardobjects.Ef -import de.gematik.ti.erp.app.nfc.model.command.HealthCardCommand -import de.gematik.ti.erp.app.nfc.model.command.executeSuccessfulOn -import de.gematik.ti.erp.app.nfc.model.command.generalAuthenticate -import de.gematik.ti.erp.app.nfc.model.command.manageSecEnvWithoutCurves -import de.gematik.ti.erp.app.nfc.model.command.read -import de.gematik.ti.erp.app.nfc.model.command.select -import de.gematik.ti.erp.app.nfc.model.exchange.KeyDerivationFunction.getAES128Key -import de.gematik.ti.erp.app.nfc.model.identifier.FileIdentifier -import de.gematik.ti.erp.app.nfc.model.identifier.ShortFileIdentifier -import de.gematik.ti.erp.app.utils.Bytes -import io.github.aakira.napier.Napier -import org.bouncycastle.asn1.ASN1EncodableVector -import org.bouncycastle.asn1.ASN1ObjectIdentifier -import org.bouncycastle.asn1.DERApplicationSpecific -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject -import org.bouncycastle.crypto.engines.AESEngine -import org.bouncycastle.crypto.macs.CMac -import org.bouncycastle.crypto.params.KeyParameter -import org.bouncycastle.util.encoders.Hex -import java.math.BigInteger -import java.security.SecureRandom - -private const val SECRET_KEY_REFERENCE = 2 // Reference of secret key for PACE (CAN) -private const val AES_BLOCK_SIZE = 16 -private const val BYTE_LENGTH = 8 -private const val MAX = 64 -private const val TAG_6 = 6 -private const val TAG_49 = 0x49 - -/** - * Opens a secure PACE Channel for secure messaging - * - * picc = card - * pcd = smartphone - */ -suspend fun NfcCardChannel.establishTrustedChannel(cardAccessNumber: String): PaceKey { - val randomGenerator = SecureRandom() - - suspend fun step0ReadSupportedPaceParameters(step1: suspend (paceInfo: PaceInfo) -> PaceKey): PaceKey { - HealthCardCommand.select(selectParentElseRoot = false, readFirst = true).executeSuccessfulOn( - this - ) - - HealthCardCommand.read(ShortFileIdentifier(Ef.Version2.SFID), 0).executeSuccessfulOn(this).let { - check(HealthCardVersion2.of(it.apdu.data).isEGK21()) { "Invalid eGK Version." } - } - - HealthCardCommand.select(FileIdentifier(Ef.CardAccess.FID), false) - .executeSuccessfulOn(this) - - val paceInfo = PaceInfo(HealthCardCommand.read().executeOn(this).apdu.data) - - HealthCardCommand.manageSecEnvWithoutCurves( - CardKey(SECRET_KEY_REFERENCE), - false, - paceInfo.paceInfoProtocolBytes - ).executeSuccessfulOn(this) - - return step1(paceInfo) - } - - suspend fun step1EphemeralPublicKeyFirst( - paceInfo: PaceInfo, - step2: suspend ( - paceInfo: PaceInfo, - nonceSInt: BigInteger, - pcdSkX1: BigInteger, - pcdPk1: ByteArray, - ) -> PaceKey, - ): PaceKey { - val nonceZBytes = HealthCardCommand.generalAuthenticate(true).executeSuccessfulOn(this).apdu.data - - Napier.d("nonceZBytes: ${Hex.toHexString(nonceZBytes)}") - - val nonceZBytesEncoded = extractKeyObjectEncoded(nonceZBytes) - val canBytes = cardAccessNumber.toByteArray() - val aes128Key = getAES128Key(canBytes, KeyDerivationFunction.Mode.PASSWORD) - val encKey = KeyParameter(aes128Key) - - val nonceS = ByteArray(AES_BLOCK_SIZE) - AESEngine().apply { - init(false, encKey) - processBlock(nonceZBytesEncoded, 0, nonceS, 0) - } - val nonceSInt = BigInteger(1, nonceS) - - val pk1Pcd = ByteArray(paceInfo.ecCurve.fieldSize / BYTE_LENGTH) - randomGenerator.nextBytes(pk1Pcd) - - val pcdSkX1 = BigInteger(1, pk1Pcd) - val pcdPkSkX1 = paceInfo.ecPointG.multiply(pcdSkX1) - - return step2(paceInfo, nonceSInt, pcdSkX1, pcdPkSkX1.getEncoded(false)) - } - - suspend fun step2EphemeralPublicKeySecond( - paceInfo: PaceInfo, - nonceSInt: BigInteger, - pcdSkX1: BigInteger, - pcdPk1: ByteArray, - step3: suspend ( - paceInfo: PaceInfo, - pcdSkX2: BigInteger, - pcdPkS2: ByteArray, - ) -> PaceKey, - ): PaceKey { - val piccPk1Bytes = - HealthCardCommand.generalAuthenticate(true, pcdPk1, 1).executeSuccessfulOn(this).apdu.data - - Napier.d("piccPk1Bytes: ${Hex.toHexString(piccPk1Bytes)}") - - val piccPk1BytesEncoded = extractKeyObjectEncoded(piccPk1Bytes) - val y1 = byteArrayToECPoint(piccPk1BytesEncoded, paceInfo.ecCurve) - val x2 = ByteArray(paceInfo.ecCurve.fieldSize / BYTE_LENGTH) - randomGenerator.nextBytes(x2) - - val sharedSecretP = y1.multiply(pcdSkX1) - val pointGS = paceInfo.ecPointG.multiply(nonceSInt).add(sharedSecretP) - - val pcdSkX2 = BigInteger(1, x2) - val pcdPkS2 = pointGS.multiply(pcdSkX2) - - return step3(paceInfo, pcdSkX2, pcdPkS2.getEncoded(false)) - } - - suspend fun step3MutualAuthentication( - paceInfo: PaceInfo, - pcdSkX2: BigInteger, - pcdPkS2: ByteArray, - step4: suspend ( - piccMacDerived: ByteArray, - pcdMac: ByteArray, - ) -> Boolean, - ): PaceKey { - val piccPk2Bytes = - HealthCardCommand.generalAuthenticate(true, pcdPkS2, 3).executeSuccessfulOn(this).apdu.data - - Napier.d("piccPk2: ${Hex.toHexString(piccPk2Bytes)}") - - val piccPk2 = extractKeyObjectEncoded(piccPk2Bytes) - - val piccPk2ECPoint = byteArrayToECPoint(piccPk2, paceInfo.ecCurve) - val sharedSecretK = piccPk2ECPoint.multiply(pcdSkX2) - val sharedSekBigInt = sharedSecretK.normalize().xCoord.toBigInteger() - - Napier.d("BIGINT:$sharedSekBigInt") - - val sharedSecretKBytes: ByteArray = - Bytes.bigIntToByteArray(sharedSecretK.normalize().xCoord.toBigInteger()) - - Napier.d("sharedSecretKBytes: ${Hex.toHexString(sharedSecretKBytes)}") - - val paceKey = PaceKey( - getAES128Key(sharedSecretKBytes, KeyDerivationFunction.Mode.ENC), - getAES128Key(sharedSecretKBytes, KeyDerivationFunction.Mode.MAC) - ) - - val pcdMac = deriveMac(paceKey.mac, piccPk2, paceInfo.protocolID) - val piccMacDerived = deriveMac(paceKey.mac, pcdPkS2, paceInfo.protocolID) - - require(step4(piccMacDerived, pcdMac)) - - return paceKey - } - - fun step4VerifyPcdAndPiccMac( - piccMacDerived: ByteArray, - pcdMac: ByteArray, - ): Boolean { - val piccMacBytes = - HealthCardCommand.generalAuthenticate(false, pcdMac, 5) - .executeSuccessfulOn(this).apdu.data - - Napier.d("macPiccBytes: ${Hex.toHexString(piccMacBytes)}") - val piccMac = extractKeyObjectEncoded(piccMacBytes) - - return piccMac.contentEquals(piccMacDerived) - } - - /** - * Negotiate the PaceKey and return the object - */ - Napier.d("start step 0 ----") - return step0ReadSupportedPaceParameters { paceInfo -> - Napier.d("start step 1 ----") - step1EphemeralPublicKeyFirst(paceInfo) { _, nonceSInt, pcdSkX1, pcdPk1 -> - Napier.d("start step 2 ----") - step2EphemeralPublicKeySecond(paceInfo, nonceSInt, pcdSkX1, pcdPk1) { _, pcdSkX2, pcdPkS2 -> - Napier.d("start step 3 ----") - step3MutualAuthentication(paceInfo, pcdSkX2, pcdPkS2) { piccMacDerived, pcdMac -> - Napier.d("start step 4 ----") - step4VerifyPcdAndPiccMac(piccMacDerived, pcdMac) - } - } - } - } -} - -private fun createAsn1AuthToken(ecPoint: ByteArray, protocolID: String): ByteArray { - val asn1EncodableVector = ASN1EncodableVector() - asn1EncodableVector.add(ASN1ObjectIdentifier(protocolID)) - asn1EncodableVector.add( - DERTaggedObject( - false, - TAG_6, - DEROctetString(ecPoint) - ) - ) - return DERApplicationSpecific(TAG_49, asn1EncodableVector).encoded -} - -private fun deriveMac(mac: ByteArray, publicKey: ByteArray, protocolID: String): ByteArray = - CMac(AESEngine(), MAX).apply { - init(KeyParameter(mac)) - - val authToken = createAsn1AuthToken(publicKey, protocolID) - update(authToken, 0, authToken.size) - }.let { - ByteArray(it.macSize).apply { - it.doFinal(this, 0) - } - } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/DataObject.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/DataObject.kt deleted file mode 100644 index 80c621af..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/DataObject.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.tagobjects - -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject - -private const val DO_87_TAG = 0x07 -private const val DO_81_EXTRACTED_TAG = 0x81 -private const val DO_81_TAG = 0x01 - -/** - * Data object with TAG 87 - * - * @param data byte array with extracted data from plain CommandApdu or encrypted ResponseApdu - * @param tag int with extracted tag number - */ -class DataObject(val data: ByteArray, val tag: Byte = 0) { - val taggedObject: DERTaggedObject - get() = - if (tag == DO_81_EXTRACTED_TAG.toByte()) { - DERTaggedObject(false, DO_81_TAG, DEROctetString(data)) - } else { - DERTaggedObject(false, DO_87_TAG, DEROctetString(data)) - } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/LengthObject.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/LengthObject.kt deleted file mode 100644 index 14d419a8..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/LengthObject.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.tagobjects - -import de.gematik.ti.erp.app.nfc.model.command.EXPECTED_LENGTH_WILDCARD_SHORT -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject - -private const val DO_97_TAG = 0x17 -private const val BYTE_MASK = 0xFF -private const val BYTE_VALUE = 8 - -/** - * Length object with TAG 97 - * - * @param le extracted expected length from plain CommandApdu - */ -class LengthObject(le: Int) { - private var leData = ByteArray(0) - val taggedObject: DERTaggedObject - get() = DERTaggedObject(false, DO_97_TAG, DEROctetString(leData)) - - init { - if (le >= 0) { - leData = when { - le == EXPECTED_LENGTH_WILDCARD_SHORT -> { - byteArrayOf(0x00) - } - le > EXPECTED_LENGTH_WILDCARD_SHORT -> { - byteArrayOf( - (le shr BYTE_VALUE and BYTE_MASK).toByte(), - (le and BYTE_MASK).toByte() - ) - } - else -> { - byteArrayOf(le.toByte()) - } - } - } - } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/MacObject.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/MacObject.kt deleted file mode 100644 index debbc9b0..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/nfc/model/tagobjects/MacObject.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.nfc.model.tagobjects - -import de.gematik.ti.erp.app.utils.Bytes.padData -import org.bouncycastle.asn1.DEROctetString -import org.bouncycastle.asn1.DERTaggedObject -import org.bouncycastle.crypto.engines.AESEngine -import org.bouncycastle.crypto.macs.CMac -import org.bouncycastle.crypto.params.KeyParameter -import java.io.ByteArrayOutputStream - -private const val DO_8E_TAG = 0x0E -private const val MAC_SIZE = 8 -private const val BLOCK_SIZE = 16 - -/** - * Mac object with TAG 8E (cryptographic checksum) - * - * - * @param header byte array with extracted header from plain CommandApdu - * @param commandDataOutput ByteArrayOutputStream with extracted data and expected length from plain CommandApdu - * @param kMac byte array with Session key for message authentication - * @param ssc byte array with send sequence counter - */ -class MacObject( - private val header: ByteArray? = null, - private val commandOutput: ByteArrayOutputStream, - private val kMac: ByteArray, - private val ssc: ByteArray -) { - private var _mac: ByteArray = ByteArray(BLOCK_SIZE) - val mac: ByteArray - get() = _mac.copyOf() - - val taggedObject: DERTaggedObject - get() = - DERTaggedObject(false, DO_8E_TAG, DEROctetString(_mac)) - - init { - calculateMac() - } - - private fun calculateMac() { - val cbcMac = getCMac(ssc, kMac) - - if (header != null) { - val paddedHeader = padData(header, BLOCK_SIZE) - cbcMac.update(paddedHeader, 0, paddedHeader.size) - } - if (commandOutput.size() > 0) { - val paddedData = padData(commandOutput.toByteArray(), BLOCK_SIZE) - cbcMac.update(paddedData, 0, paddedData.size) - } - cbcMac.doFinal(_mac, 0) - - _mac = _mac.copyOfRange(0, MAC_SIZE) - } - - private fun getCMac(secureMessagingSSC: ByteArray, kMac: ByteArray): CMac = - CMac(AESEngine()).apply { - init(KeyParameter(kMac)) - update(secureMessagingSSC, 0, secureMessagingSSC.size) - } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt index 8c140a63..67f9826b 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/LocalDataSource.kt @@ -71,4 +71,11 @@ class LocalDataSource { fun loadCommunications(): Flow> { return communications } + + suspend fun invalidate() = lock.withLock { + auditEvents.value = emptyList() + medicationDispenses.value = emptyList() + tasks.value = emptyList() + communications.value = emptyList() + } } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt index ed74e7d6..5f722f87 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt @@ -80,10 +80,5 @@ class PrescriptionRepository( val medicationDispenses = mapper.mapFhirMedicationDispenseToSimpleMedicationDispense(it) localDataSource.saveMedicationDispenses(medicationDispenses) } - - suspend fun downloadCommunications(): Result = - remoteDataSource.getAllCommunications().mapCatching { - val medicationDispenses = mapper.mapFhirMedicationDispenseToSimpleMedicationDispense(it) - localDataSource.saveMedicationDispenses(medicationDispenses) - } + suspend fun invalidate() = localDataSource.invalidate() } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/model/SimpleTask.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/model/SimpleTask.kt index 907a2972..3b21e195 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/model/SimpleTask.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/model/SimpleTask.kt @@ -24,7 +24,6 @@ import java.time.LocalDateTime data class SimpleTask( val taskId: String, - val accessCode: String, val lastModified: LocalDateTime? = null, val organization: String, // an organization can contain multiple authors diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionDetailScreen.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionDetailScreen.kt index 0dc3c505..97c39480 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionDetailScreen.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionDetailScreen.kt @@ -102,7 +102,7 @@ fun PrescriptionDetailsScreen( Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colors.background, + color = MaterialTheme.colors.background ) { Box { LazyColumn( @@ -315,7 +315,7 @@ private fun WasSubstitutedHint() = @Composable private fun DosageInformation( - prescription: PrescriptionUseCaseData.PrescriptionDetails, + prescription: PrescriptionUseCaseData.PrescriptionDetails ) { val infoText = prescription.dosageInstruction() ?: App.strings.presDetailDosageDefaultInfo() @@ -330,7 +330,7 @@ private fun DosageInformation( ), image = { HintSmallImage(painterResource("images/doctor_circle.webp"), innerPadding = it) }, title = null, - body = { Text(infoText) }, + body = { Text(infoText) } ) } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreen.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreen.kt index 2db2bcfc..f9d3d27c 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreen.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreen.kt @@ -22,30 +22,19 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ButtonElevation -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextButton -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,11 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerMoveFilter -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import de.gematik.ti.erp.app.common.App import de.gematik.ti.erp.app.common.Dialog @@ -88,7 +73,6 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale -@OptIn(ExperimentalMaterialApi::class) @Composable fun PrescriptionScreen( navigation: Navigation @@ -236,7 +220,7 @@ fun expiresOrAcceptedUntil( } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun Prescription( modifier: Modifier, @@ -274,58 +258,3 @@ private fun Prescription( Text(prescribedOnText, style = AppTheme.typography.captionl) } } - -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) -@Composable -fun IconHoverButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - elevation: ButtonElevation? = ButtonDefaults.elevation(), - content: @Composable BoxScope.() -> Unit -) { - val colors = ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.neutral100, - contentColor = AppTheme.colors.neutral400, - ) - val contentColor by colors.contentColor(enabled) - val coScope = rememberCoroutineScope() - var size by remember { mutableStateOf(IntSize.Zero) } - val press = remember(size) { - PressInteraction.Press(Offset(size.width / 2f, size.height / 2f)) - } - Surface( - modifier = modifier - .onSizeChanged { - size = it - } - .pointerMoveFilter( - onEnter = { - coScope.launch { - interactionSource.emit(press) - } - false - }, - onExit = { - coScope.launch { - interactionSource.emit(PressInteraction.Release(press)) - } - false - } - ), - shape = CircleShape, - color = colors.backgroundColor(enabled).value, - contentColor = contentColor.copy(alpha = 1f), - elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp, - onClick = onClick, - enabled = enabled, - role = Role.Button, - interactionSource = interactionSource, - indication = rememberRipple() - ) { - Box(Modifier.padding(PaddingDefaults.Small)) { - content() - } - } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt index 68a1805a..43b9d1ea 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/PrescriptionViewModel.kt @@ -40,7 +40,7 @@ import org.kodein.di.bindings.ScopeCloseable class PrescriptionViewModel( private val dispatchersProvider: DispatchersProvider, - private val prescriptionUseCase: PrescriptionUseCase, + private val prescriptionUseCase: PrescriptionUseCase ) : ScopeCloseable { private val deleteScope = CoroutineScope(dispatchersProvider.io()) private val deleteResult = MutableSharedFlow>() @@ -63,7 +63,7 @@ class PrescriptionViewModel( prescriptions = prescriptions, prescriptionsType = type, selectedPrescription = null, - selectedPrescriptionAudits = emptyList(), + selectedPrescriptionAudits = emptyList() ) ) } else { @@ -76,7 +76,7 @@ class PrescriptionViewModel( prescriptions = prescriptions, prescriptionsType = type, selectedPrescription = details, - selectedPrescriptionAudits = audits, + selectedPrescriptionAudits = audits ) } ) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt index 812f70ed..91617fb5 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/ui/model/PrescriptionScreenData.kt @@ -28,7 +28,7 @@ object PrescriptionScreenData { val prescriptions: List, val prescriptionsType: PrescriptionUseCase.PrescriptionType, val selectedPrescription: PrescriptionUseCaseData.PrescriptionDetails?, - val selectedPrescriptionAudits: List, + val selectedPrescriptionAudits: List ) @Immutable diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/PrescriptionMapper.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/PrescriptionMapper.kt index 4cc44539..dc7e31bc 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/PrescriptionMapper.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/PrescriptionMapper.kt @@ -33,13 +33,12 @@ class PrescriptionMapper { fun mapSimpleTask(task: SimpleTask, redeemedOn: LocalDate?) = PrescriptionUseCaseData.Prescription( taskId = task.taskId, - accessCode = task.accessCode, name = task.medicationText, expiresOn = task.expiresOn, acceptUntil = task.acceptUntil, organization = task.organization, authoredOn = task.authoredOn, - redeemedOn = redeemedOn, + redeemedOn = redeemedOn ) fun mapSimpleTaskDetailed(task: SimpleTask, dispenses: List) = @@ -51,7 +50,7 @@ class PrescriptionMapper { medicationDispenses = dispenses.map { mapSimpleMedicationDispense(it) }, insurance = requireNotNull(task.rawKBVBundle.extractInsurance()), organization = requireNotNull(task.rawKBVBundle.extractOrganization()), - medicationRequest = requireNotNull(task.rawKBVBundle.extractMedicationRequest()), + medicationRequest = requireNotNull(task.rawKBVBundle.extractMedicationRequest()) ) fun mapSimpleMedicationDispense(dispense: SimpleMedicationDispense) = diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt index c6e4bc7f..3ead95ec 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/usecase/model/PrescriptionUseCaseData.kt @@ -32,7 +32,6 @@ object PrescriptionUseCaseData { @Stable data class Prescription( val taskId: String, - val accessCode: String, val name: String?, val organization: String, val authoredOn: LocalDateTime, diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolScreen.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolScreen.kt index 457a035a..76ab0257 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolScreen.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolScreen.kt @@ -77,7 +77,7 @@ fun ProtocolScreen() { Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colors.background, + color = MaterialTheme.colors.background ) { Box { LazyColumn( diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolViewModel.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolViewModel.kt index bedfb615..4193ae5b 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolViewModel.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/ui/ProtocolViewModel.kt @@ -21,7 +21,7 @@ package de.gematik.ti.erp.app.protocol.ui import de.gematik.ti.erp.app.protocol.usecase.ProtocolUseCase class ProtocolViewModel( - protocolUseCase: ProtocolUseCase, + protocolUseCase: ProtocolUseCase ) { val protocolSearchFlow = protocolUseCase.loadProtocol() } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/usecase/ProtocolUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/usecase/ProtocolUseCase.kt index a82ace88..703229b1 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/usecase/ProtocolUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/protocol/usecase/ProtocolUseCase.kt @@ -61,11 +61,11 @@ class ProtocolUseCase( nextKey = nextKey, prevKey = prevKey, itemsBefore = if (prevKey != null) count else 0, - itemsAfter = if (nextKey != null) count else 0, + itemsAfter = if (nextKey != null) count else 0 ) }, onFailure = { - LoadResult.Error(it) - }) + LoadResult.Error(it) + }) } } diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/CertUtils.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/CertUtils.kt deleted file mode 100644 index 74894026..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/CertUtils.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau - -import org.bouncycastle.asn1.ASN1ObjectIdentifier -import org.bouncycastle.cert.X509CertificateHolder -import org.bouncycastle.cert.ocsp.BasicOCSPResp -import org.bouncycastle.jcajce.provider.asymmetric.ec.KeyFactorySpi -import org.bouncycastle.jce.interfaces.ECPublicKey -import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder -import org.bouncycastle.operator.bc.BcECContentVerifierProviderBuilder -import java.time.Instant -import java.util.Date - -/** - * Refer to gemSpec_OID. - */ -fun X509CertificateHolder.containsIdentifierOid(oid: ByteArray) = - this.getExtension(ASN1ObjectIdentifier("1.3.36.8.3.3")).encoded.contains(oid) - -fun List>.filterByOIDAndOCSPResponse( - oid: ByteArray, - validOcspResponses: List, - timestamp: Instant -): List> = - filter { it.first().containsIdentifierOid(oid) } - .filter { chain -> - validOcspResponses.find { validOcspResponse -> - val producedAt = validOcspResponse.producedAt.toInstant() - - validOcspResponse.findValidCert(chain.first().serialNumber)?.let { - val thisUpdate = it.thisUpdate.toInstant() - - (producedAt <= thisUpdate) && (thisUpdate <= timestamp) && - it.matchesIssuer(chain[1]) - // TODO not present in test responses - // && it.matchesHashOfCertificate(chain[0]) - } ?: false - } != null - } - -fun List>.filterBySignature(timestamp: Instant) = - filter { it.size >= 3 } - .mapNotNull { chain -> - try { - chain.reduceRight { it, prev -> - it.checkSignatureWith(prev) - it.checkValidity(timestamp) - it - } - - chain - } catch (e: Exception) { - null - } - } - -fun List.validateSubjectDN(cnPrefix: String): List = - this.filter { - it.subjectDNContainsCNPrefixWithNumber(cnPrefix) - } - -fun X509CertificateHolder.checkSignatureWith(signatureCertificate: X509CertificateHolder) { - val verifier = - BcECContentVerifierProviderBuilder(DefaultDigestAlgorithmIdentifierFinder()) - .build(signatureCertificate) - - require(this.isSignatureValid(verifier)) -} - -/** - * Validates the common name form the distinguished name. - * Throws an exception if the common name is not present or the pattern `CN=GEM.KOMP-CA + number` doesn't match. - */ -internal fun X509CertificateHolder.subjectDNContainsCNPrefixWithNumber(cnPrefix: String): Boolean = - this.subject.toString().split(",").find { it.startsWith("CN") }?.let { - """CN=$cnPrefix\d+.*""".toRegex().matches(it) - } ?: false - -/** - * Checks if the [this] certificate is valid at the provided time. - * Throws an exception if the check fails. - */ -fun X509CertificateHolder.checkValidity(timestamp: Instant) { - require(isValidOn(Date.from(timestamp))) -} - -fun X509CertificateHolder.extractECPublicKey(): ECPublicKey { - return KeyFactorySpi.EC().generatePublic(subjectPublicKeyInfo)!! as ECPublicKey -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/ClientCrypto.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/ClientCrypto.kt deleted file mode 100644 index 7a689135..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/ClientCrypto.kt +++ /dev/null @@ -1,356 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau - -import java.security.SecureRandom -import java.security.interfaces.ECPublicKey -import java.util.Locale -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import okhttp3.Headers -import okhttp3.HttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import okio.Buffer - -/* -VAU steps: - -A.1 receive http response from fhir client -A.1.1 get long-term cert from vau or keystore -A.1.2 validate cert with vau ocsp -A.2 encrypt http req and a random AES key with ECIES as payload for http request to vau -A.3 send vau http request - -B.1 get response from vau -B.2 decrypt vau response payload with AES key from A.2 -B.3 pass http response to fhir client - -C.1 goto A.1 -*/ - -private val defaultContentType = "application/octet-stream".toMediaTypeOrNull() -private const val byteSpace: Byte = 32 - -/** - * Trusted execution environment channel specifications according to `gemSpec_Krypt 7`. - */ -class VauChannelSpec constructor( - /** - * Version byte. E.g. `'1'.toByte()`. - */ - val version: Byte, - /** - * Request id size in bytes. - */ - val requestIdSize: Int, - /** - * Symmetrical decryption key size in bytes. - */ - val decryptionKeySize: Int, - - val specEcies: VauEciesSpec, - val specAesGcm: VauAesGcmSpec, -) { - /** - * Raw request data holding the previously used request id (hex encoded) and the decryption key. - * The payload is the actual encrypted inner request to the VAU. - */ - class RawRequestData( - val requestIdHex: ByteArray, - val decryptionKey: SecretKey, - val payload: ByteArray - ) - - /** - * Encrypts a byte array as the inner request. - */ - fun encryptRawVauRequest( - innerHttp: ByteArray, - - bearer: ByteArray, - publicKey: ECPublicKey, - - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): RawRequestData { - val decryptionKey = KeyGenerator.getInstance("AES", cryptoConfig.provider).apply { - init(this@VauChannelSpec.decryptionKeySize * 8) - }.generateKey() - - val requestId = ByteArray(this.requestIdSize).apply { - SecureRandom().nextBytes(this) - } - - return encryptRawVauRequest( - innerHttp = innerHttp, - bearer = bearer, - publicKey = publicKey, - requestId = requestId, - decryptionKey = decryptionKey, - cryptoConfig = cryptoConfig - ) - } - - /** - * Encrypt raw request data as the inner request. - */ - fun encryptRawVauRequest( - innerHttp: ByteArray, - - bearer: ByteArray, - publicKey: ECPublicKey, - - requestId: ByteArray, - decryptionKey: SecretKey, - - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): RawRequestData { - val symmetricalKeyHex = decryptionKey.encoded!!.toLowerCaseHex() - val requestIdHex = requestId.toLowerCaseHex() - val composedInnerHttp = - composeInnerHttp(innerHttp, this.version, bearer, requestIdHex, symmetricalKeyHex) - - return RawRequestData( - requestIdHex = requestIdHex, - decryptionKey = decryptionKey, - payload = Ecies.encrypt(publicKey, specEcies, composedInnerHttp, cryptoConfig) - ) - } - - private fun composeInnerHttp( - innerHttp: ByteArray, - version: Byte, - bearer: ByteArray, - requestId: ByteArray, - symmetricalKey: ByteArray - ) = - ByteArray(5 + bearer.size + requestId.size + symmetricalKey.size + innerHttp.size).apply { - this[0] = version - this[1] = byteSpace - bearer.copyInto(this, 2) - this[2 + bearer.size] = byteSpace - requestId.copyInto(this, 3 + bearer.size) - this[3 + bearer.size + requestId.size] = byteSpace - symmetricalKey.copyInto(this, 4 + bearer.size + requestId.size) - this[4 + bearer.size + requestId.size + symmetricalKey.size] = byteSpace - innerHttp.copyInto(this, 5 + bearer.size + requestId.size + symmetricalKey.size) - } - - /** - * Decrypt raw response data. - */ - fun decryptRawVauResponse( - encryptedInnerHttp: ByteArray, - decryptionKey: SecretKey, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): ByteArray = - AesGcm.decrypt(decryptionKey, specAesGcm, encryptedInnerHttp, cryptoConfig) - - /** - * Returns the minimum response size in bytes assuming the `request id` to be hex encoded. - * This includes the space separating the `header` from the actual encrypted body. - */ - val minResponseSize: Int = 3 + requestIdSize * 2 - - /** - * Encrypts an okhttp [Request] and wraps it within a new outer request. - * The outer request points to the location `$baseUrl/$userpseudonym`. - * The bearer token is extracted from the authorization header of the [innerRequest] - * and is required to be prefixed with `Bearer`; i.e. `Authorization = Bearer ab12cd34d42fs324`. - * - * @return the encrypted request and [RawRequestData] of the actual encryption process. - */ - fun encryptHttpRequest( - innerRequest: Request, - userpseudonym: String, - publicKey: ECPublicKey, - baseUrl: HttpUrl, - - cryptoConfig: VauCryptoConfig = defaultCryptoConfig, - ): Pair { - val bearer = requireNotNull( - innerRequest.header("Authorization") - ?.takeIf { it.startsWith("Bearer") } - ) - .removePrefix("Bearer") - .trim() - - val payload = innerRequest.toRawVauInnerHttpRequest(baseUrl) - - val encryptedRawRequest = encryptRawVauRequest( - innerHttp = payload, - bearer = bearer.encodeToByteArray(), - publicKey = publicKey, - cryptoConfig = cryptoConfig - ) - - val body = encryptedRawRequest.payload.toRequestBody(defaultContentType) - - return Pair( - Request.Builder() - .url(requireNotNull(baseUrl.resolve("VAU/$userpseudonym"))) - .post(body) - .header("Content-Length", body.contentLength().toString()) - .build(), - encryptedRawRequest - ) - } - - /** - * Decrypts a response from the VAU containing the encrypted inner response as payload. - * The [previousInnerRequest] is only required to match the decrypted response with its previous request. - * - * Additional checks include the minimum length of the decrypted inner response and - * that the request id matches with its request. - * - * @return the decrypted inner response with the user pseudonym. - */ - fun decryptHttpResponse( - outerResponse: Response, - previousInnerRequest: Request, - - rawRequestData: RawRequestData, - - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): Pair { - require(outerResponse.isSuccessful) - val body = requireNotNull(outerResponse.body) { "VAU response body empty" } - require(body.contentType() == defaultContentType) { "VAU response body has wrong content type" } - - val userpseudonym = outerResponse.header("Userpseudonym") - - val p = decryptRawVauResponse( - encryptedInnerHttp = body.bytes(), - decryptionKey = rawRequestData.decryptionKey, - cryptoConfig = cryptoConfig - ) - - require(p.size >= this.minResponseSize) - - require(p[0] == this.version) - require( - p.copyOfRange(2, this.minResponseSize - 1) - .contentEquals(rawRequestData.requestIdHex) - ) { "VAU response contains wrong request id" } - - val innerResponse = p.copyOfRange(this.minResponseSize, p.size) - - return Pair(innerResponse.toVauInnerHttpResponse(previousInnerRequest), userpseudonym) - } - - companion object { - @JvmField - val V1 = VauChannelSpec( - version = '1'.code.toByte(), - requestIdSize = 16, - decryptionKeySize = 16, - specEcies = VauEciesSpec.V1, - specAesGcm = VauAesGcmSpec.V1, - ) - } -} - -// http - -/** - * Create a raw http request according to rfc2616 from a okhttp [Request]. - * - * This request path will be transformed according to the following: - * - * [baseUrl] path: `.../VAU/` - * [this] path: `.../VAU/Task/123` - * - * resulting path: `/Task/123` - * - * Throws an exception if [baseUrl] doesn't contain a trailing `/` or [this] doesn't contain the [baseUrl]. - */ -fun Request.toRawVauInnerHttpRequest( - baseUrl: HttpUrl, - protocol: Protocol = Protocol.HTTP_1_1 -): ByteArray = - this.let { req -> - require(baseUrl.querySize == 0) - require(baseUrl.fragment == null) - require(baseUrl.pathSegments.last() == "") // trailing `/` - - val urlEncoded = req.url.toString() - val baseUrlEncoded = baseUrl.toString() - require(urlEncoded.startsWith(baseUrlEncoded)) - - val urlWithoutBase = "/" + urlEncoded.removePrefix(baseUrlEncoded) - - Buffer().apply { - // request line - writeUtf8("${req.method} $urlWithoutBase ${protocol.toString().uppercase(Locale.getDefault())}\r\n") - // host - writeUtf8("Host: ${url.host}\r\n") - // other headers - req.headers.forEach { h -> - writeUtf8("${h.first}: ${h.second}\r\n") - } - writeUtf8("Content-Length: ${req.body?.contentLength() ?: 0}\r\n") - // body separation - writeUtf8("\r\n") - // body if present - req.body?.writeTo(this) - }.readByteArray() - } - -private fun Response.Builder.parseResponseLine(l: String): Response.Builder = - l.split(" ", limit = 3).let { - require(it.size == 3) { "Invalid status line!" } - - this.protocol(Protocol.get(it[0].lowercase())).code(it[1].toInt()).message(it[2]) - } - -/** - * Creates an okhttp [Response] from a raw http request according to rfc2616. - */ -fun String.toVauInnerHttpResponse(req: Request): Response = - this.split("\r\n\r\n", limit = 2).let { rawHttp -> - val rawHeader = rawHttp.first().split("\r\n").iterator() - - Response.Builder().apply { - require(rawHeader.hasNext()) { "Response is empty!" } - // status line - this.parseResponseLine(rawHeader.next()) - - // might be empty - val headers = Headers.Builder().apply { - rawHeader.forEachRemaining { headerLine -> - add(headerLine) - } - }.build() - this.headers(headers) - - headers["Content-Type"]?.takeIf { rawHttp.size == 2 }?.let { - this.body(rawHttp[1].toResponseBody(it.toMediaType())) - } ?: this.body("".toResponseBody().apply { close() }) - - this.request(req) - }.build() - } - -fun ByteArray.toVauInnerHttpResponse(req: Request): Response = - this.decodeToString().toVauInnerHttpResponse(req) diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/Crypto.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/Crypto.kt deleted file mode 100644 index be893817..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/Crypto.kt +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau - -import org.bouncycastle.crypto.digests.SHA256Digest -import org.bouncycastle.crypto.generators.HKDFBytesGenerator -import org.bouncycastle.crypto.params.HKDFParameters -import org.bouncycastle.jce.ECNamedCurveTable -import java.math.BigInteger -import java.security.KeyFactory -import java.security.KeyPair -import java.security.KeyPairGenerator -import java.security.Provider -import java.security.SecureRandom -import java.security.interfaces.ECPublicKey -import java.security.spec.ECGenParameterSpec -import javax.crypto.Cipher -import javax.crypto.KeyAgreement -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -/** - * Configuration enabling a custom security provider and secure random. - */ -interface VauCryptoConfig { - val provider: Provider - val random: SecureRandom -} - -internal val defaultCryptoConfig: VauCryptoConfig - get() = error("default crypto config should not be used") - -/** - * Refer to gemSpec_Krypt A_20161. - */ -class VauEciesSpec constructor( - val version: Byte, - val info: ByteArray, - /** - * IV size in bytes. - */ - val ivSize: Int, - /** - * Symmetrical key size in bytes. - */ - val aesSize: Int -) { - companion object { - @JvmField - val V1 = VauEciesSpec( - version = 0x01.toByte(), - info = "ecies-vau-transport".toByteArray(), - ivSize = 12, - aesSize = 16 - ) - } -} - -/** - * Refer to gemSpec_Krypt `A_20161-01` - */ -object Ecies { - internal fun generateCipher( - ivSpec: IvParameterSpec, - ourECKeyPair: KeyPair, - otherECPublicKey: ECPublicKey, - spec: VauEciesSpec, - mode: Int, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ) = - Cipher.getInstance("AES/GCM/NoPadding", cryptoConfig.provider).apply { - val secret = KeyAgreement.getInstance("ECDH", cryptoConfig.provider).apply { - init(ourECKeyPair.private, cryptoConfig.random) - doPhase(otherECPublicKey, true) - }.generateSecret() - - val aesKey = ByteArray(spec.aesSize).apply { - HKDFBytesGenerator(SHA256Digest()).apply { - init(HKDFParameters(secret, null, spec.info)) - }.generateBytes(this, 0, this.size) - } - - init(mode, SecretKeySpec(aesKey, "AES"), ivSpec) - } - - internal fun encrypt( - spec: VauEciesSpec, - plaintext: ByteArray, - ivSpec: IvParameterSpec, - ourPublicKey: ECPublicKey, - cipher: Cipher - ): ByteArray { - val ciphertext = cipher.doFinal(plaintext) - - require(ciphertext.size - 16 == plaintext.size) { "ECIES encryption failed!" } - - val x = ourPublicKey.w.affineX.toByteArray() - val y = ourPublicKey.w.affineY.toByteArray() - - return ByteArray(1 + 32 * 2 + spec.ivSize + ciphertext.size).apply { - // due two's-complement representation, x & y may contain leading zeros resulting - // in a byte array of 33 elements; - // therefore we copy them in reverse order to ignore the first byte in this case - y.copyInto(this, 1 + 32 + 32 - y.size) - x.copyInto(this, 1 + 32 - x.size) - set(0, spec.version) - - ivSpec.iv.copyInto(this, 1 + 32 + 32) - ciphertext.copyInto(this, 1 + 32 + 32 + spec.ivSize) - } - } - - fun encrypt( - otherECPublicKey: ECPublicKey, - spec: VauEciesSpec, - plaintext: ByteArray, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): ByteArray { - val ivBytes = ByteArray(spec.ivSize).apply { - cryptoConfig.random.nextBytes(this) - } - val ivSpec = IvParameterSpec(ivBytes) - - val eKp = KeyPairGenerator.getInstance("EC", cryptoConfig.provider) - .apply { initialize(ECGenParameterSpec("brainpoolP256r1"), cryptoConfig.random) } - .generateKeyPair() - - val cipher = - generateCipher(ivSpec, eKp, otherECPublicKey, spec, Cipher.ENCRYPT_MODE, cryptoConfig) - return encrypt(spec, plaintext, ivSpec, eKp.public as ECPublicKey, cipher) - } - - fun decrypt( - ourECKeyPair: KeyPair, - spec: VauEciesSpec, - ciphertext: ByteArray, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): ByteArray = - ciphertext.let { - require(it[0] == spec.version) { "Invalid version byte: ${it[0]} != ${spec.version}" } - require(it.size > (1 + 32 * 2 + spec.ivSize)) { "Ciphertext too small!" } - - val x = BigInteger(1, it.copyOfRange(1, 1 + 32)) - val y = BigInteger(1, it.copyOfRange(1 + 32, 1 + 32 * 2)) - - val curveSpec = ECNamedCurveTable.getParameterSpec("brainpoolP256r1") - val otherPublicKey = org.bouncycastle.jce.spec.ECPublicKeySpec( - curveSpec.curve.createPoint(x, y), - curveSpec - ).let { pubKeySpec -> - KeyFactory.getInstance("EC", cryptoConfig.provider) - .generatePublic(pubKeySpec) as ECPublicKey - } - - val ivSpec = IvParameterSpec(it, 1 + 32 * 2, spec.ivSize) - - generateCipher(ivSpec, ourECKeyPair, otherPublicKey, spec, Cipher.DECRYPT_MODE, cryptoConfig) - .doFinal(ciphertext, 1 + 32 * 2 + spec.ivSize, it.size - (1 + 32 * 2 + spec.ivSize)) - } -} - -class VauAesGcmSpec constructor( - /** - * IV size in bytes. - */ - val ivSize: Int, - /** - * Tag length in bytes. - */ - val tagSize: Int -) { - companion object { - @JvmField - val V1 = VauAesGcmSpec( - ivSize = 12, - tagSize = 16 - ) - } -} - -object AesGcm { - fun encrypt( - aesKey: SecretKey, - spec: VauAesGcmSpec, - cleartext: ByteArray, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): ByteArray { - val ivBytes = ByteArray(spec.ivSize).apply { - cryptoConfig.random.nextBytes(this) - } - - val cipher = Cipher.getInstance("AES/GCM/NoPadding", cryptoConfig.provider).apply { - init(Cipher.ENCRYPT_MODE, aesKey, GCMParameterSpec(spec.tagSize * 8, ivBytes)) - }.doFinal(cleartext) - - return ivBytes + cipher - } - - fun decrypt( - aesKey: SecretKey, - spec: VauAesGcmSpec, - ciphertext: ByteArray, - cryptoConfig: VauCryptoConfig = defaultCryptoConfig - ): ByteArray = - Cipher.getInstance("AES/GCM/NoPadding", cryptoConfig.provider).apply { - init( - Cipher.DECRYPT_MODE, - aesKey, - GCMParameterSpec(spec.tagSize * 8, ciphertext, 0, spec.ivSize) - ) - }.doFinal(ciphertext, spec.ivSize, ciphertext.size - spec.ivSize) -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/OCSPUtils.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/OCSPUtils.kt deleted file mode 100644 index 3626f753..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/OCSPUtils.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau - -import org.bouncycastle.asn1.ASN1ObjectIdentifier -import org.bouncycastle.asn1.isismtt.ocsp.CertHash -import org.bouncycastle.cert.X509CertificateHolder -import org.bouncycastle.cert.ocsp.BasicOCSPResp -import org.bouncycastle.cert.ocsp.SingleResp -import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder -import org.bouncycastle.operator.bc.BcDigestCalculatorProvider -import org.bouncycastle.operator.bc.BcECContentVerifierProviderBuilder -import java.math.BigInteger -import java.time.Duration -import java.time.Instant - -private val certHashOid = ASN1ObjectIdentifier("1.3.36.8.3.13") - -/** - * Returns true if the required `CertHash` extension within the [SingleResp] matches the - * calculated hash of [cert] (i.e. VAU or IDP certificate). - */ -fun SingleResp.matchesHashOfCertificate(cert: X509CertificateHolder) = - try { - val certHash = CertHash.getInstance( - requireNotNull(this.getExtension(certHashOid)) { "CertHash extension required" } - ) - - val digest = BcDigestCalculatorProvider().get(certHash.hashAlgorithm).apply { - outputStream.apply { - write(cert.toASN1Structure().getEncoded("DER")) - close() - } - }.digest - - certHash.certificateHash.contentEquals(digest) - } catch (e: Exception) { - false - } - -fun BasicOCSPResp.findValidCert(serialNumber: BigInteger): SingleResp? = - this.responses - .find { it.certID.serialNumber == serialNumber } - ?.takeIf { it.certStatus == null } - -/** - * This checks whether the contained certificate id is the one of the issuer certificate. - */ -fun SingleResp.matchesIssuer(issuerCert: X509CertificateHolder) = - this.certID?.matchesIssuer(issuerCert, BcDigestCalculatorProvider()) ?: false - -/** - * Checks the signature over the field 'tbsResponseData' with [signatureCertificate]. - * Throws an exception if the check fails. - */ -fun BasicOCSPResp.checkSignatureWith(signatureCertificate: X509CertificateHolder) { - val verifier = - BcECContentVerifierProviderBuilder(DefaultDigestAlgorithmIdentifierFinder()) - .build(signatureCertificate) - - require(this.isSignatureValid(verifier)) { "OCSP response signature couldn't be validated against its signer certificate" } -} - -/** - * Checks if the field 'producedAt' plus [maxAge] is after [timestamp] and 'producedAt' is before [timestamp]. - * Throws an exception if the check fails. - */ -fun BasicOCSPResp.checkValidity(maxAge: Duration, timestamp: Instant) { - requireNotNull( - this.producedAt?.toInstant()?.takeIf { it + maxAge >= timestamp && timestamp >= it } - ) { "OCSP response expired" } -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/Utils.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/Utils.kt deleted file mode 100644 index 8f3f97e9..00000000 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/Utils.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2022 gematik GmbH - * - * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by - * the European Commission - subsequent versions of the EUPL (the Licence); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the Licence is distributed on an "AS IS" basis, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and - * limitations under the Licence. - * - */ - -package de.gematik.ti.erp.app.vau - -// hex - -private fun hexMap(index: Int) = - when (index) { - in 0..9 -> (index + 48).toByte() - in 10..15 -> (index + 97 - 10).toByte() - else -> error("wrong hex") - } - -/** - * Converts the bytes into an hex representation as bytes. - * - * E.g. `byteArrayOf(0, 5, 2).toLowerCaseHex()` result in `[48, 48, 48, 53, 48, 50]`. - */ -fun ByteArray.toLowerCaseHex(): ByteArray { - val buffer = ByteArray(this.size * 2) - for (i in this.indices) { - (this[i].toInt() and 0xFF).let { - buffer[i * 2] = hexMap((it / 16) % 16) - buffer[i * 2 + 1] = hexMap(it % 16) - } - } - return buffer -} - -/** - * Searches [other] within [this] array of bytes. - */ -fun ByteArray.contains(other: ByteArray): Boolean { - if (this.isEmpty() || other.isEmpty() || other.size > this.size) { - return false - } - - for (i in 0..(this.size - other.size)) { - if (this[i] == other[0] && this.size - other.size - i >= 0) { - var found = true - for (j in other.indices) { - if (this[i + j] != other[j]) { - found = false - break - } - } - if (found) { - return true - } - } - } - return false -} diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt index f56f461f..98b2df40 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/interceptor/VauChannelInterceptor.kt @@ -48,7 +48,7 @@ class VauException(e: Exception) : IOException(e) class VauChannelInterceptor( private val truststore: TruststoreUseCase, private val cryptoConfig: VauCryptoConfig, - private val dispatchProvider: DispatchersProvider, + private val dispatchProvider: DispatchersProvider ) : Interceptor { // `gemSpec_Krypt A_20175` private var previousUserAlias = "0" diff --git a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt index d46f7137..c77833f4 100644 --- a/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt +++ b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/vau/usecase/TruststoreUseCase.kt @@ -64,7 +64,7 @@ class TruststoreUseCase( private val config: TruststoreConfig, private val repository: VauRepository, private val timeSourceProvider: TruststoreTimeSourceProvider, - private val trustedTruststoreProvider: TrustedTruststoreProvider, + private val trustedTruststoreProvider: TrustedTruststoreProvider ) { private val lock = Mutex() private var cachedTruststore: TrustedTruststore? = null diff --git a/documentation/test-tags.md b/documentation/test-tags.md new file mode 100644 index 00000000..611c1bb3 --- /dev/null +++ b/documentation/test-tags.md @@ -0,0 +1,15 @@ +# Visualize Test Tags + +Build the Android App with visual test tags: + +```shell +gradle :android:assembleGoogle(Pu|Tu|Ru)InternalDebug -Pbuildkonfig.flavor=google(Pu|Tu|Ru)Internal -PDEBUG_VISUAL_TEST_TAGS=true +``` + +and install the app: + +```shell +adb install android/build/outputs/apk/google(Pu|Tu|Ru)Internal/debug/android-google(Pu|Tu|Ru)Internal-debug.apk +``` + +To change any test tags edit `android/src/main/java/de/gematik/ti/erp/app/TestTags.kt`. diff --git a/gradle.properties b/gradle.properties index 6a8cf850..93d2e195 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,24 +16,23 @@ org.gradle.jvmargs=-Xmx4g -Dkotlin.daemon.jvm.options\="-Xmx4g" -XX:+UseParallel # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official org.gradle.parallel=true -kotlin.mpp.enableGranularSourceSetsMetadata=true kotlin.mpp.stability.nowarn=true # -# BUG! see https://github.com/square/moshi/issues/1463 -android.jetifier.ignorelist=moshi-1.13.0 -# buildkonfig.flavor=googleTuInternal # VERSION_CODE=1 VERSION_NAME=1.0 -USER_AGENT=eRp-App-Android/1.2.1 GMTIK/eRezeptApp +USER_AGENT=eRp-App-Android/1.4.9 GMTIK/eRezeptApp # DATA_PROTECTION_LAST_UPDATED = 2022-01-06 # +# orchestrator; can be de.gematik.ti.erp.app.test.test.CucumberTest +TEST_INSTRUMENTATION_ORCHESTRATOR=androidx.test.runner.AndroidJUnitRunner +# # Service URLs. Values in local.properties have precedence and will override these. # #PROD Environment @@ -43,13 +42,15 @@ BASE_SERVICE_URI_PU=https://erp.app.ti-dienste.de/ ERP_API_KEY_GOOGLE_PU= # PU Huawei ERP_API_KEY_HUAWEI_PU= +# PU Desktop +ERP_API_KEY_DESKTOP_PU= # #TEST Ref Environment IDP_SERVICE_URI_TR= BASE_SERVICE_URI_TR= -# TU +# TR ERP_API_KEY_GOOGLE_TR= -# TU Huawei +# TR Huawei ERP_API_KEY_HUAWEI_TR= # #TEST Environment @@ -59,6 +60,8 @@ BASE_SERVICE_URI_TU= ERP_API_KEY_GOOGLE_TU= # TU Huawei ERP_API_KEY_HUAWEI_TU= +# TU Desktop +ERP_API_KEY_DESKTOP_TU= # #REF Environment IDP_SERVICE_URI_RU= @@ -67,6 +70,12 @@ BASE_SERVICE_URI_RU= ERP_API_KEY_GOOGLE_RU= # RU Huawei ERP_API_KEY_HUAWEI_RU= +# RU Desktop +ERP_API_KEY_DESKTOP_RU= + +#REF-DEV Environment +IDP_SERVICE_URI_RU_DEV= +BASE_SERVICE_URI_RU_DEV= # # Pharmacy service # @@ -75,14 +84,6 @@ PHARMACY_API_KEY= PHARMACY_SERVICE_URI_TEST= PHARMACY_API_KEY_TEST= # -# Analytics -# -PIWIK_TRACKER_URI= -#Piwik ID Google -PIWIK_TRACKER_ID_GOOGLE= -#Piwik ID Huawei -PIWIK_TRACKER_ID_HUAWEI= -# # VAU # VAU_OCSP_RESPONSE_MAX_AGE=12 @@ -95,3 +96,8 @@ APP_TRUST_ANCHOR_BASE64_TEST= DEBUG_TEST_IDS_ENABLED=true # SAFETYNET_API_KEY= +# +DEFAULT_VIRTUAL_HEALTH_CARD_CERTIFICATE= +DEFAULT_VIRTUAL_HEALTH_CARD_PRIVATE_KEY= +# +MAPS_API_KEY= diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 961acabe..d9e2cc7a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Nov 09 23:18:41 CET 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/plugins/dependencies/build.gradle.kts b/plugins/dependencies/build.gradle.kts index 7f998d3a..bf0e5dfa 100644 --- a/plugins/dependencies/build.gradle.kts +++ b/plugins/dependencies/build.gradle.kts @@ -17,5 +17,5 @@ gradlePlugin { } dependencies { - implementation("com.android.tools.build:gradle:7.0.3") + implementation("com.android.tools.build:gradle:7.2.0") } diff --git a/plugins/dependencies/src/main/kotlin/de/gematik/ti/erp/AppDependenciesPlugin.kt b/plugins/dependencies/src/main/kotlin/de/gematik/ti/erp/AppDependenciesPlugin.kt index 5670be44..2ec12a62 100644 --- a/plugins/dependencies/src/main/kotlin/de/gematik/ti/erp/AppDependenciesPlugin.kt +++ b/plugins/dependencies/src/main/kotlin/de/gematik/ti/erp/AppDependenciesPlugin.kt @@ -51,6 +51,7 @@ class AppDependenciesPlugin : Plugin { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + buildToolsVersion = "33.0.0" } } } @@ -64,69 +65,72 @@ class AppDependenciesPlugin : Plugin { object Dependencies { const val MinimumSdkVersion = 24 - const val CompileSdkVersion = 31 - const val TargetSdkVersion = 31 + const val CompileSdkVersion = 32 + const val TargetSdkVersion = 32 object DependencyInjection { - fun hilt(module: String) = "com.google.dagger:hilt-$module:2.40.5" - fun kodein(module: String) = "org.kodein.di:kodein-$module:7.10.0" + fun kodein(module: String) = "org.kodein.di:kodein-$module:7.11.0" } - object Tracker { - const val piwik = "pro.piwik.sdk:piwik-sdk:1.0.1" - } + object Tracker object DataMatrix { - const val mlkitBarcodeScanner = "com.google.mlkit:barcode-scanning:17.0.0" + const val mlkitBarcodeScanner = "com.google.mlkit:barcode-scanning:17.0.2" // Zxing - used for generating 2d data matrix codes - const val zxing = "com.google.zxing:core:3.4.1" + const val zxing = "com.google.zxing:core:3.5.0" } object KotlinX { - fun coroutines(target: String) = "org.jetbrains.kotlinx:kotlinx-coroutines-$target:1.6.0" + fun coroutines(target: String) = "org.jetbrains.kotlinx:kotlinx-coroutines-$target:1.6.4" object Test { val coroutinesTest = coroutines("test") } } - + object Lottie { + const val lottieVersion = "5.0.3" + const val lottie = "com.airbnb.android:lottie-compose:$lottieVersion" + } object PlayServices { const val location = "com.google.android.gms:play-services-location:19.0.1" const val safetynet = "com.google.android.gms:play-services-safetynet:18.0.1" + const val appReview = "com.google.android.play:review-ktx:2.0.0" + const val appUpdate = "com.google.android.play:app-update-ktx:2.0.0" } object Android { const val desugaring = "com.android.tools:desugar_jdk_libs:1.1.5" - const val appcompat = "androidx.appcompat:appcompat:1.4.0" + const val appcompat = "androidx.appcompat:appcompat:1.4.1" const val legacySupport = "androidx.legacy:legacy-support-v4:1.0.0" - const val coreKtx = "androidx.core:core-ktx:1.6.0" + const val coreKtx = "androidx.core:core-ktx:1.7.0" const val datastorePreferences = "androidx.datastore:datastore-preferences:1.0.0" const val biometric = "androidx.biometric:biometric:1.1.0" - + const val webkit = "androidx.webkit:webkit:1.4.0" const val security = "androidx.security:security-crypto:1.1.0-alpha03" - fun lifecycle(module: String) = "androidx.lifecycle:lifecycle-$module:2.4.0" + fun lifecycle(module: String) = "androidx.lifecycle:lifecycle-$module:2.5.1" - const val composeNavigation = "androidx.navigation:navigation-compose:2.4.0-rc01" - const val composeHiltNavigation = "androidx.hilt:hilt-navigation-compose:1.0.0-rc01" + const val composeNavigation = "androidx.navigation:navigation-compose:2.4.2" const val composeActivity = "androidx.activity:activity-compose:1.4.0" const val composePaging = "androidx.paging:paging-compose:1.0.0-alpha14" - const val constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc02" const val cameraViewVersion = "1.0.0-alpha32" const val cameraVersion = "1.1.0-alpha12" fun camera(module: String, version: String = cameraVersion) = "androidx.camera:camera-$module:$version" const val processPhoenix = "com.jakewharton:process-phoenix:2.1.2" + const val imageCropper = "com.github.CanHub:Android-Image-Cropper:4.2.1" object Test { - const val runner = "androidx.test:runner:1.4.1-alpha03" - const val orchestrator = "androidx.test:orchestrator:1.4.1-beta01" + const val runner = "androidx.test:runner:1.4.1-alpha07" + const val orchestrator = "androidx.test:orchestrator:1.4.2-alpha04" + const val services = "androidx.test.services:test-services:1.4.2-alpha04" const val archCore = "androidx.arch.core:core-testing:2.1.0" - const val core = "androidx.test:core:1.4.1-alpha03" + const val core = "androidx.test:core:1.4.1-alpha07" + const val rules = "androidx.test:rules:1.4.1-alpha07" const val espresso = "androidx.test.espresso:espresso-core:3.4.0" const val junitExt = "androidx.test.ext:junit:1.1.3" - const val navigation = "androidx.navigation:navigation-testing:2.3.5" + const val navigation = "androidx.navigation:navigation-testing:2.4.2" } } @@ -135,20 +139,17 @@ class AppDependenciesPlugin : Plugin { } object Logging { - const val timber = "com.jakewharton.timber:timber:5.0.1" - const val napier = "io.github.aakira:napier:2.3.0" + const val napier = "io.github.aakira:napier:2.6.1" const val slf4jNoOp = "org.slf4j:slf4j-nop:2.0.0-alpha5" } object Serialization { - fun moshi(target: String) = "com.squareup.moshi:$target:1.13.0" - const val kotlinXJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0" - + const val kotlinXJson = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3" const val fhir = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4:5.5.1" } object Crypto { - const val jose4j = "org.bitbucket.b_c:jose4j:0.7.9" + const val jose4j = "org.bitbucket.b_c:jose4j:0.7.12" fun bouncyCastle(provider: String, targetPlatform: String = "jdk15to18") = "org.bouncycastle:$provider-$targetPlatform:1.70" @@ -165,13 +166,14 @@ class AppDependenciesPlugin : Plugin { object Database { const val sqlCipher = "net.zetetic:android-database-sqlcipher:4.5.0" - fun room(target: String) = "androidx.room:room-$target:2.3.0" + fun room(target: String) = "androidx.room:room-$target:2.4.1" + const val realm = "io.realm.kotlin:library-base:1.0.2" object Test { val roomTesting = room("testing") } } - internal const val composeVersion = "1.1.0-rc01" + const val composeVersion = "1.2.1" object Compose { const val compiler = "androidx.compose.compiler:compiler:$composeVersion" @@ -187,7 +189,7 @@ class AppDependenciesPlugin : Plugin { const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$composeVersion" - fun accompanist(module: String) = "com.google.accompanist:accompanist-$module:0.22.0-rc" + fun accompanist(module: String) = "com.google.accompanist:accompanist-$module:0.23.0" object Test { const val ui = "androidx.compose.ui:ui-test:$composeVersion" @@ -196,14 +198,14 @@ class AppDependenciesPlugin : Plugin { } object PasswordStrength { - const val zxcvbn = "com.nulab-inc:zxcvbn:1.5.2" + const val zxcvbn = "com.nulab-inc:zxcvbn:1.7.0" } object Test { fun mockk(module: String) = "io.mockk:$module:1.12.2" const val junit4 = "junit:junit:4.13.2" const val snakeyaml = "org.yaml:snakeyaml:1.30" - const val json = "org.json:json:20211205" + const val json = "org.json:json:20220320" } } } @@ -268,6 +270,9 @@ object App { fun test(init: AppDependenciesPlugin.Dependencies.Test.() -> Unit) = AppDependenciesPlugin.Dependencies.Test.init() + + fun lottie(init: AppDependenciesPlugin.Dependencies.Lottie.() -> Unit) = + AppDependenciesPlugin.Dependencies.Lottie.init() } fun app(init: App.() -> Unit) = App.init() diff --git a/plugins/resource-generation/build.gradle.kts b/plugins/resource-generation/build.gradle.kts index dbd1dfff..7100150f 100644 --- a/plugins/resource-generation/build.gradle.kts +++ b/plugins/resource-generation/build.gradle.kts @@ -2,12 +2,13 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `kotlin-dsl` - kotlin("plugin.serialization") version "1.5.31" + kotlin("jvm") version "1.7.0" + kotlin("plugin.serialization") version "1.7.0" `java-gradle-plugin` } tasks.withType() { - kotlinOptions.jvmTarget = "11" + kotlinOptions.jvmTarget = "15" kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" } @@ -19,7 +20,7 @@ gradlePlugin { } dependencies { - implementation("com.squareup:kotlinpoet:1.10.1") + implementation("com.squareup:kotlinpoet:1.11.0") implementation("io.github.pdvrieze.xmlutil:serialization-jvm:0.83.0") implementation("io.github.pdvrieze.xmlutil:core-jvm:0.83.0") } diff --git a/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/networkSecurityConfigGen/AndroidNetworkConfigGeneratorTask.kt b/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/networkSecurityConfigGen/AndroidNetworkConfigGeneratorTask.kt index dbc99d96..0a74ad6c 100644 --- a/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/networkSecurityConfigGen/AndroidNetworkConfigGeneratorTask.kt +++ b/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/networkSecurityConfigGen/AndroidNetworkConfigGeneratorTask.kt @@ -118,7 +118,7 @@ open class AndroidNetworkConfigGeneratorTask : DefaultTask() { val config = xml.decodeFromString(serializer, resourceFile.readBytes().decodeToString()) FileSpec.builder(packagePath, "Pinning") - .addComment("\nDO NOT MODIFY - GENERATED ON ${LocalDateTime.now()}\n") + .addFileComment("\nDO NOT MODIFY - GENERATED ON ${LocalDateTime.now()}\n") .addImport("okhttp3", "CertificatePinner") .addAnnotation( AnnotationSpec.builder(ClassName("", "Suppress")) diff --git a/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/stringResGen/AndroidStringResourceGeneratorTask.kt b/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/stringResGen/AndroidStringResourceGeneratorTask.kt index 3b0ca401..0a7e0f57 100644 --- a/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/stringResGen/AndroidStringResourceGeneratorTask.kt +++ b/plugins/resource-generation/src/main/kotlin/de/gematik/ti/erp/stringResGen/AndroidStringResourceGeneratorTask.kt @@ -61,7 +61,7 @@ internal data class ResPlural( override val name: String, @XmlElement(true) @XmlSerialName("item", "", "") - val items: List, + val items: List ) : ResTranslatable() @Serializable @@ -191,7 +191,7 @@ open class AndroidStringResourceGeneratorTask : DefaultTask() { } FileSpec.builder(packagePath, "StringResource") - .addComment("\nDO NOT MODIFY - GENERATED ON ${LocalDateTime.now()}\n") + .addFileComment("\nDO NOT MODIFY - GENERATED ON ${LocalDateTime.now()}\n") .addAnnotation( AnnotationSpec.builder(ClassName("", "Suppress")) .addMember("%S", "RedundantVisibilityModifier") diff --git a/rules/build.gradle.kts b/rules/build.gradle.kts index 8940e149..2ef802b4 100644 --- a/rules/build.gradle.kts +++ b/rules/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "1.5.31" + kotlin("jvm") version "1.7.0" } repositories { @@ -23,8 +23,8 @@ tasks.test { dependencies { implementation(kotlin("stdlib")) - implementation("com.pinterest.ktlint:ktlint-core:0.43.2") + implementation("com.pinterest.ktlint:ktlint-core:0.45.2") testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") - testImplementation("com.pinterest.ktlint:ktlint-test:0.43.2") + testImplementation("com.pinterest.ktlint:ktlint-test:0.45.2") } diff --git a/rules/src/test/kotlin/de/gematik/ti/erp/LicenceRuleTest.kt b/rules/src/test/kotlin/de/gematik/ti/erp/LicenceRuleTest.kt index cf7ef247..6fda014b 100644 --- a/rules/src/test/kotlin/de/gematik/ti/erp/LicenceRuleTest.kt +++ b/rules/src/test/kotlin/de/gematik/ti/erp/LicenceRuleTest.kt @@ -38,7 +38,9 @@ class LicenceRuleTest { ) val expected = LintError( - 1, 1, "licence-header", + 1, + 1, + "licence-header", "Licence header missing" ) @@ -79,7 +81,9 @@ class LicenceRuleTest { ) val expected = LintError( - 1, 1, "licence-header", + 1, + 1, + "licence-header", "Licence header missing" ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4efa2226..8dd4e3ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,8 @@ pluginManagement { repositories { + maven("https://oss.sonatype.org/content/repositories/snapshots/") + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") google() - jcenter() gradlePluginPortal() mavenCentral() } @@ -22,9 +23,12 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + maven("https://oss.sonatype.org/content/repositories/snapshots/") + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") google() - jcenter() mavenCentral() + maven ("https://jitpack.io") + jcenter() } } @@ -40,8 +44,12 @@ includeBuild("smartcard-wrapper") { } } -include(":android") -include(":desktop") -include(":common") +//includeBuild("modules/fhir-parser") { +// dependencySubstitution { +// substitute(module("de.gematik.ti.erp.app:fhir-parser")).using(project(":")) +// } +//} + +include(":android", ":desktop", ":common") rootProject.name = "E-Rezept" diff --git a/smartcard-wrapper/build.gradle.kts b/smartcard-wrapper/build.gradle.kts index 5bb7173e..bf3293b3 100644 --- a/smartcard-wrapper/build.gradle.kts +++ b/smartcard-wrapper/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "1.5.31" + kotlin("jvm") version "1.7.0" } version = 1.0