diff --git a/.gitignore b/.gitignore index 4ede3b26..4837181c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,13 @@ .cxx Jenkinsfile -Jenkinsfile_Archive -Jenkinsfile_GitHubPublish -Jenkinsfile_Desktop -Jenkinsfile_GitHubPublishFasttrack +Nightly.Jenkinsfile +Archive.Jenkinsfile +GitHubPublish.Jenkinsfile +Desktop.Jenkinsfile +UpdateTools.Jenkinsfile +DependencyReport.Jenkinsfile ci-overrides.properties nexus-init.gradle.kts +doc/security_doku +android/src/androidTest diff --git a/EspressoTest.Jenkinsfile b/EspressoTest.Jenkinsfile new file mode 100644 index 00000000..8f120ce7 --- /dev/null +++ b/EspressoTest.Jenkinsfile @@ -0,0 +1,156 @@ +@Library('gematik-jenkins-shared-library') _ + +pipeline { + options { + disableConcurrentBuilds() + buildDiscarder logRotator(artifactNumToKeepStr: '1', numToKeepStr: '5') + } + agent { label 'k8-android' } + + triggers { cron('@midnight') } + + environment { + UNIQUE_UPLOAD_NAME = "reports_${getTimestamp()}" + PATH = "/home/jenkins/agent/workspace/eRp-Android-Testautomation/google-cloud-sdk/bin:${env.PATH}" + } + + stages { + stage('Prepare python libs') { + steps { + sh('pip install junitparser') + } + } + stage('Load & extract gcloud cli tool') { + steps { + sh('curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-394.0.0-linux-x86_64.tar.gz') + sh('tar -xf google-cloud-cli-394.0.0-linux-x86_64.tar.gz') + sh('./google-cloud-sdk/install.sh -q') + } + } + stage('Authenticate with gcloud & set project') { + steps { + withCredentials([file(credentialsId: 'gematik-erx-app-testenv-secret-file', variable: 'CLOUD_ACCESS_JSON')]) { + sh('gcloud auth activate-service-account --key-file=$CLOUD_ACCESS_JSON') + } + sh('gcloud config set project gematik-erx-app-testenv') + sh('gcloud firebase test android models list') + } + } + stage("Download Gradle Caches") { + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + nexusFileDownload("E-Rezept-App/Android-Cache/gradle-caches.zip", "gradle-caches.zip") + sh("mkdir ${GRADLE_USER_HOME}/caches") + unzip(zipFile: "gradle-caches.zip", dir: "${GRADLE_USER_HOME}/caches/") + sh("chmod -R +x ${GRADLE_USER_HOME}/caches") + } + } + } + stage('Build') { + steps { + sh("./gradlew android:assembleGoogleTuInternalDebug -Pbuildkonfig.flavor=googleTuInternal") + sh("./gradlew android:assembleGoogleTuInternalDebugAndroidTest -Pbuildkonfig.flavor=googleTuInternal") + } + } + stage('Archive') { + steps { + archiveArtifacts(artifacts: 'android/build/outputs/apk/**/*.apk') + } + } +// stage('Upload & Start Test') { +// steps { +// withCredentials([string(credentialsId: 'MCD_exklusive', variable: 'CLOUD_ACCESS_KEY')]) { +// sh label:'Push Android build to cloud', script:''' +// curl --location --request POST "https://mobiledevicecloud.t-systems-mms.eu/api/v1/test-run/execute-test-run?deviceQueries=@os='android'" \ +// -H "Authorization: Bearer $CLOUD_ACCESS_KEY" \ +// -F "executionType=espresso" \ +// -F "runningType=coverage" \ +// -F "useTestOrchestrator=true" \ +// -F "clearPackageData=true" \ +// -F "testApp=@./android/build/outputs/apk/androidTest/googleTuInternal/debug/android-googleTuInternal-debug-androidTest.apk" \ +// -F "app=@./android/build/outputs/apk/googleTuInternal/debug/android-googleTuInternal-debug.apk" > test-result.json +// ''' +// } +// sh('cat test-result.json') +// } +// } + stage('Upload & Start Test') { + steps { + sh(''' + gcloud firebase test android run \ + --type=instrumentation \ + --app=./android/build/outputs/apk/googleTuInternal/debug/android-googleTuInternal-debug.apk \ + --test=./android/build/outputs/apk/androidTest/googleTuInternal/debug/android-googleTuInternal-debug-androidTest.apk \ + --device=model=redfin,version=30 \ + --use-orchestrator \ + --environment-variables=clearPackageData=true \ + --results-dir=$UNIQUE_UPLOAD_NAME \ + --results-bucket=test-results-fe06447a10a2 || true + ''') + } + } + stage('Download reporting') { + steps { + sh('gsutil cp gs://test-results-fe06447a10a2/$UNIQUE_UPLOAD_NAME/redfin-30-en-portrait/test_result_1.xml ./test_result_1.xml') + } + } + } + + post { +// always { +// stage('De-Authenticate with gcloud') { +// steps { +// sh('./google-cloud-sdk/bin/gcloud auth ') +// } +// } +// } +// success { +// script { +// def result = readJSON file: 'test-result.json' +// +// if (result['data'].containsKey('Error reason')) { +// emailext( +// subject: "Android Test Run - TSys Cloud Failure", +// body: '${FILE, path="test-result.json"}', +// to: "vl_ti_erp_app_android@gematik.de,marcel.basquitt@gematik.de,tanja.rahn@gematik.de,christian.lange@gematik.de" +// ) +// } else { +// def totalNumberOfTest = result['data']['Total number of tests'] as Integer +// def numberOfPassed = result['data']['Number of passed tests'] as Integer +// def successRate = ((numberOfPassed / totalNumberOfTest) * 100) as Integer +// +// emailext( +// subject: "Android Test Run - Passed ${successRate}% of ${totalNumberOfTest} tests", +// body: '${FILE, path="test-result.json"}', +// to: 'tobias.schwerdtfeger@gematik.de' +// // to: "vl_ti_erp_app_android@gematik.de,marcel.basquitt@gematik.de,tanja.rahn@gematik.de,christian.lange@gematik.de" +// ) +// } +// } + success { + script { + sh('python ./ci/junit-report.py test_result_1.xml > report.txt') + + def results = (readFile('report.txt')).split("\n\n") + def subject = results[0] + def body = results[1] + + emailext( + subject: subject, + body: body, + to: "vl_ti_erp_app_android@gematik.de,marcel.basquitt@gematik.de,tanja.rahn@gematik.de,christian.lange@gematik.de,patrick.dargel@gematik.de, daniel.storl@gematik.de" + ) + } + } + failure { + emailext to: "vl_ti_erp_app_android@gematik.de", + subject: "Espresso Test Run - Failed", + body: "" + } + } +} + +def getTimestamp() { + def now = new Date() + return now.format("yyyyMMddHHmm"); +} diff --git a/Multibranch.Jenkinsfile b/Multibranch.Jenkinsfile new file mode 100644 index 00000000..0621bd73 --- /dev/null +++ b/Multibranch.Jenkinsfile @@ -0,0 +1,90 @@ +@Library('gematik-jenkins-shared-library') _ + +def CREDENTIAL_ID_NEXUS = "Nexus" + +pipeline { + options { + disableConcurrentBuilds() + ansiColor('xterm') + copyArtifactPermission('*') + } + + agent { label 'k8-android' } + + stages { + stage("Download Gradle Caches") { + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + nexusFileDownload("E-Rezept-App/Android-Cache/gradle-caches.zip", "gradle-caches.zip") + sh("mkdir ${GRADLE_USER_HOME}/caches") + unzip(zipFile: "gradle-caches.zip", dir: "${GRADLE_USER_HOME}/caches/") + sh("chmod -R +x ${GRADLE_USER_HOME}/caches") + } + } + } + + stage('Build') { + steps { + gradleNexusCredentials( + { + sh label: "starting build...", script: "./gradlew assembleGoogleTuInternalDebug -Pbuildkonfig.flavor=googleTuInternal" + }, + CREDENTIAL_ID_NEXUS) + } + } + + stage('AppCenter Upload') { + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + withCredentials([[ + $class : "UsernamePasswordMultiBinding", + credentialsId : "AppCenter-eRp-Android-Develop_u_p", + usernameVariable: "UNUSED", + passwordVariable: "APPCENTER_API_TOKEN"]]) + { + appCenter apiToken: APPCENTER_API_TOKEN, + ownerName: "Gematik", + appName: 'eRezept-Android-Develop', + pathToApp: 'android/build/outputs/apk/**/android-googleTuInternal-debug.apk', + distributionGroups: 'Collaborators' + } + } + } + } + + stage('UnitTests') { + steps { + sh './gradlew testGoogleTuInternaDebug -Pbuildkonfig.flavor=googleTuInternal' + } + } + + stage('Gradle Cache Upload') { + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + sh("./gradlew --stop") + zip(zipFile: "gradle-caches.zip", archive: false, dir: "${GRADLE_USER_HOME}/caches/", overwrite: true) + nexusFileUpload("gradle-caches.zip", "E-Rezept-App/Android-Cache/gradle-caches.zip") + } + } + } + } + + post { + success { + emailext attachLog: true, + to: "vl_ti_erp_app_android@gematik.de", + subject: "Job ${env.JOB_NAME} build ${env.BUILD_NUMBER}: ${currentBuild.currentResult}", + body: "${currentBuild.currentResult}: Job ${env.JOB_NAME} build ${env.BUILD_NUMBER}" + } + failure { + emailext attachLog: true, + to: "vl_ti_erp_app_android@gematik.de", + subject: "Job ${env.JOB_NAME} build ${env.BUILD_NUMBER}: ${currentBuild.currentResult}", + body: "" + } + always { + archiveArtifacts artifacts: '**/*-debug.apk', allowEmptyArchive: true + junit testResults: '**/testGoogleTuInternalDebugUnitTest/*.xml,**/desktopTest/*.xml', allowEmptyResults: true + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 0ea3829b..68e1d691 100644 --- a/README.md +++ b/README.md @@ -97,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/Release.Jenkinsfile b/Release.Jenkinsfile new file mode 100644 index 00000000..f67c1799 --- /dev/null +++ b/Release.Jenkinsfile @@ -0,0 +1,260 @@ +@Library('gematik-jenkins-shared-library') _ + +def CREDENTIAL_ID_GEMATIK_GIT = "GITLAB.tst_tt_build.Username_Password" +def CREDENTIAL_ID_NEXUS = "Nexus" +def REPO_URL = "https://gitlab.prod.ccs.gematik.solutions/git/erezept/app/erp-app-android.git" +def GRADLE_PARAMS = "" +def BUILD_TASK = "" +def FLAVOR = "" +def CHECKOUT_BRANCH = "master" + +def appCenterUpload(credentialsId, appName, pathToApp, distributionGroups) { + withCredentials([[ + $class : "UsernamePasswordMultiBinding", + credentialsId : credentialsId, + usernameVariable: "UNUSED", + passwordVariable: "APPCENTER_API_TOKEN"]]) + { + appCenter apiToken: APPCENTER_API_TOKEN, + ownerName: "Gematik", + appName: appName, + pathToApp: pathToApp, + distributionGroups: distributionGroups + } +} + +pipeline { + options { + skipDefaultCheckout() + disableConcurrentBuilds() + ansiColor('xterm') + copyArtifactPermission('*') + } + agent { label 'k8-android' } + + parameters { + choice(name: 'Packaging', choices: ['assemble', 'bundle'], description: 'Type of packaging, APK = assemble for e.g. manual installation on devices, AAB = bundle for Store distribution') + choice(name: 'Store', choices: ['Google', 'Huawei', 'Konnektathon'], description: 'Which store to build for') + choice(name: 'Environment', choices: ['Pu', 'Tu', 'Ru', 'Devru'], description: 'Build type') + choice(name: 'Build_Type', choices: ['Release', 'Debug'], description: 'Build type') + string(name: 'VERSION_CODE', defaultValue: '', description: 'App version code (Versioning in Play/AppGallery)') + string(name: 'VERSION_NAME', defaultValue: '', description: 'App version name (User-facing app version)') + string(name: 'BRANCH_OVERRIDE', defaultValue: '', description: 'Branch specifier to use for checkout. Default is Master if no value is specified.') + booleanParam(name: 'Unit_Test', defaultValue: true, description: 'Run Unit Tests') + booleanParam(name: 'OWASP_Dep_Check', defaultValue: true, description: 'Run OWASP Dependency Check') + booleanParam(name: 'Archive_Build_Log', defaultValue: false, description: 'Archive Jenkins Build Log to Git') + } + + stages { + stage('Process Parameters') { + steps { + script { + if (!params.BRANCH_OVERRIDE.isEmpty()) { + CHECKOUT_BRANCH = params.BRANCH_OVERRIDE + } + if (!params.VERSION_CODE.isEmpty() && !params.VERSION_NAME.isEmpty()) { + GRADLE_PARAMS += + " -PVERSION_CODE=${params.VERSION_CODE}" + + " -PVERSION_NAME=${params.VERSION_NAME}" + } + BUILD_TASK += params.Packaging + params.Store + params.Environment + if (params.Build_Type == "Release") { + BUILD_TASK += "ExternalRelease" + } else { + BUILD_TASK += "InternalDebug" + } + FLAVOR += params.Store.toLowerCase() + params.Environment + if (params.Build_Type == "Release") { + FLAVOR += "External" + } else { + FLAVOR += "Internal" + } + echo "CHECKOUT_BRANCH: ${CHECKOUT_BRANCH}" + echo "params.Packaging: ${params.Packaging}" + echo "params.Store: ${params.Store}" + echo "params.Environment: ${params.Environment}" + echo "params.Build_Type: ${params.Build_Type}" + echo "BUILD_TASK: ${BUILD_TASK}" + echo "FLAVOR: ${FLAVOR}" + } + } + } + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', branches: [[name: "*/${CHECKOUT_BRANCH}"]], + userRemoteConfigs: [[url: REPO_URL, credentialsId: CREDENTIAL_ID_GEMATIK_GIT]] + ]) + } + } + stage("Download Gradle Caches") { + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + nexusFileDownload("E-Rezept-App/Android-Cache/gradle-caches.zip", "gradle-caches.zip") + sh("mkdir ${GRADLE_USER_HOME}/caches") + unzip(zipFile: "gradle-caches.zip", dir: "${GRADLE_USER_HOME}/caches/") + sh("chmod -R +x ${GRADLE_USER_HOME}/caches") + } + } + } + stage('Setup app signing') { + steps { + echo 'Moving Play signing keystore into place...' + withCredentials([file(credentialsId: 'erp-android-keystore-play', variable: 'KEYSTORE')]) { + sh "cp \$KEYSTORE erp-release-keystore-play.jks" + } + echo 'Moving Huawei signing keystore into place...' + withCredentials([file(credentialsId: 'erp-android-keystore-huawei', variable: 'KEYSTORE')]) { + sh "cp \$KEYSTORE erp-release-keystore-huawei.jks" + } + echo 'Moving signing.properties into place...' + withCredentials([file(credentialsId: 'android-release-signing-props-google-huawei', variable: 'PROPS')]) { + sh "cp \$PROPS signing.properties" + } + } + } + stage('Build') { + steps { + // collect info for logging + sh 'printenv' + sh 'dpkg-query -l' + + gradleNexusCredentials( + { + sh label: "starting build...", script: "./gradlew $BUILD_TASK $GRADLE_PARAMS -Pbuildkonfig.flavor=$FLAVOR" + echo 'Finished building BUILD_TASK $BUILD_TASK GRADLE_PARAMS $GRADLE_PARAMS FLAVOR $FLAVOR.' + }, + CREDENTIAL_ID_NEXUS) + } + } + + stage('AppCenter Upload') { + steps { + script { + switch(BUILD_TASK) { + case "assembleGooglePuExternalRelease": + appCenterUpload( + 'AC-eRezept-Android-Release-Google', + 'eRezept-Android-Release-Google', + 'android/build/outputs/apk/googlePuExternal/release/android-googlePuExternal-release.apk', + 'Collaborators' + ) + break + case "bundleGooglePuExternalRelease": + appCenterUpload( + 'AC-eRezept-Android-Release-Google-AAB', + 'eRezept-Android-Release-Google-AAB', + 'android/build/outputs/bundle/googlePuExternalRelease/android-googlePuExternal-release.aab', + 'Collaborators' + ) + break + case "assembleHuaweiPuExternalRelease": + appCenterUpload( + 'AC-eRezept-Android-Release-Huawei', + 'eRezept-Android-Release-Huawei', + 'android/build/outputs/apk/huaweiPuExternal/release/android-huaweiPuExternal-release.apk', + 'Collaborators' + ) + break + case "bundleHuaweiPuExternalRelease": + appCenterUpload( + 'AC-eRezept-Android-Release-Huawei-AAB', + 'eRezept-Android-Release-Huawei-AAB', + 'android/build/outputs/bundle/huaweiPuExternalRelease/android-huaweiPuExternal-release.aab', + 'Collaborators' + ) + break + case "assembleGoogleTuExternalRelease": + appCenterUpload( + 'AC-eRezept-Android-Release-TU-Google', + 'eRezept-Android-Release-TU', + 'android/build/outputs/apk/googleTuExternal/release/android-googleTuExternal-release.apk', + 'Collaborators' + ) + break + case "assembleKonnektathonRuInternalDebug": + appCenterUpload( + 'AC-eRezept-Android-Konnektathon-Release-RU', + 'eRezept-Android-Konnektathon', + 'android/build/outputs/apk/**/android-konnektathonRuInternal-debug.apk', + 'Collaborators, Public' + ) + default: + echo "No AppCenter upload for Build Task '${BUILD_TASK}'!" + break + } + } + } + } + + stage('UnitTests') { + when { + expression { + return params.Unit_Test + } + } + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + echo 'Running Unit Tests...' + sh './gradlew testGoogleTuInternaDebug -Pbuildkonfig.flavor=googleTuInternal' + echo 'Done running unit tests.' + } + } + } + stage('OWASP DepsCheck') { + when { + expression { + return params.OWASP_Dep_Check + } + } + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + echo 'Running OWASP DepCheck...' + gradleNexusCredentials( + { + sh './gradlew dependencyCheckAnalyze' + }, + CREDENTIAL_ID_NEXUS) + echo 'Done with OWASP DepCheck.' + } + } + } + stage('Archive Build Log') { + when { + expression { + return params.Archive_Build_Log + } + } + steps { + catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { + build job: 'eRp-Android-Archive-Buildlog', + parameters: [ + string(name: 'BUILDNUMBER', value: env.BUILD_NUMBER), + string(name: 'BRANCH_NAME', value: ""), + string(name: 'COMMIT_MESSAGE', value: "Buildlog ${CHECKOUT_BRANCH} - ${env.BUILD_NUMBER}") + ], + wait: true + } + } + } + } + + post { + success { + emailext attachLog: false, + to: "vl_ti_erp_app_android@gematik.de", + subject: "Job ${env.JOB_NAME} build ${env.BUILD_NUMBER}: ${currentBuild.currentResult}", + body: "${currentBuild.currentResult}: Job ${env.JOB_NAME} build ${env.BUILD_NUMBER}" + } + failure { + emailext attachLog: true, + to: "vl_ti_erp_app_android@gematik.de", + subject: "Job ${env.JOB_NAME} build ${env.BUILD_NUMBER}: ${currentBuild.currentResult}", + body: "" + } + always { + archiveArtifacts artifacts: '**/*-release.apk,**/*-release.aab,**/*-debug.apk,**/dependency-check-report.*', allowEmptyArchive: true + junit testResults: '**/testGoogleTuInternalDebugUnitTest/*.xml,**/desktopTest/*.xml', allowEmptyResults: true + } + } +} \ No newline at end of file diff --git a/ReleaseNotes.md b/ReleaseNotes.md index a703121f..02a34617 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,3 +1,27 @@ +# Release 1.4.2 +### Added +- Authentication using health insurance apps (FastTrack) +- Multiple profiles in app +- Choose you own PIN for health card +- Unlock locked health card +- Use your own pictures in profiles (i can haz cats) +- Mini-cardwall for relogin +- Show prescription types +- Status handling for prescriptions +- Show medication dispenses +- Show guide for NFC antenna position + +### Changed +- Reworked mainscreen +- Reworked onboarding +- Reworked cardwall +- Switched from Room to Realm as DB +- Addressed and commented on reported issue in biometric authentication + +### Removed +- Demo Mode + + # Release 1.2.4 ### Added diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 558706f5..8afee9bb 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -8,12 +8,13 @@ plugins { id("com.android.application") kotlin("android") id("org.jetbrains.compose") - kotlin("kapt") + kotlin("plugin.serialization") + id("com.google.devtools.ksp") + 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") } val USER_AGENT: String by overriding() @@ -23,7 +24,7 @@ val DEBUG_TEST_IDS_ENABLED: String by overriding() val VAU_OCSP_RESPONSE_MAX_AGE: String by overriding() 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,11 +33,15 @@ afterEvaluate { } } +tasks.named("preBuild") { + dependsOn(":ktlint", ":detekt") +} + licenseReport { generateCsvReport = false - generateHtmlReport = true - generateJsonReport = false - copyHtmlReportToAssets = true + generateHtmlReport = false + generateJsonReport = true + copyJsonReportToAssets = true } android { @@ -47,7 +52,8 @@ android { versionCode = VERSION_CODE.toInt() versionName = VERSION_NAME - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testApplicationId = "de.gematik.ti.erp.app.test.test" + testInstrumentationRunner = "de.gematik.ti.erp.app.test.test.MainTest" testInstrumentationRunnerArguments += "clearPackageData" to "true" testInstrumentationRunnerArguments += "useTestStorageService" to "true" @@ -57,21 +63,17 @@ android { } } } - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } + ksp { + 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") } @@ -91,7 +93,6 @@ android { kotlinOptions { jvmTarget = "1.8" freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" - freeCompilerArgs += "-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi" } testOptions { @@ -137,6 +138,7 @@ android { } else { println("No signing properties found!") } + buildTypes { val release by getting { isMinifyEnabled = true @@ -178,11 +180,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 +210,23 @@ android { pickFirsts += "win32-x86/attach_hotspot_windows.dll" } } + + composeOptions { + kotlinCompilerExtensionVersion = "1.2.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")) app { - tracker { - implementation(piwik) - } dataMatrix { implementation(mlkitBarcodeScanner) implementation(zxing) @@ -233,7 +244,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,29 +252,29 @@ 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) @@ -274,27 +285,31 @@ dependencies { } network { implementation(retrofit2("retrofit")) - implementation(retrofit2("converter-moshi")) + implementation(retrofit2KotlinXSerialization) implementation(okhttp3("okhttp")) implementation(okhttp3("logging-interceptor")) } database { + compileOnly(realm) + testCompileOnly(realm) + implementation(sqlCipher) implementation(room("runtime")) implementation(room("ktx")) - kapt(room("compiler")) + ksp(room("compiler")) } 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 { @@ -308,14 +323,16 @@ dependencies { 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) @@ -333,6 +350,9 @@ dependencies { testImplementation(json) testImplementation(mockk("mockk")) androidTestImplementation(mockk("mockk-android")) + + androidTestImplementation("io.cucumber:cucumber-android:4.9.0") + androidTestImplementation("io.cucumber:cucumber-picocontainer:4.8.1") } } } 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/28.json b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/28.json new file mode 100644 index 00000000..eaef06ff --- /dev/null +++ b/android/schemas/de.gematik.ti.erp.app.db.AppDatabase/28.json @@ -0,0 +1,435 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "69aa2e73e6873cbabeb005dcc45fc7e7", + "entities": [ + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))", + "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": "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": "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" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_profileName` ON `${TABLE_NAME}` (`profileName`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `lastAuthenticated` TEXT, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastAuthenticated", + "columnName": "lastAuthenticated", + "affinity": "TEXT", + "notNull": false + }, + { + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_profiles_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "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": "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`))", + "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" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `${TABLE_NAME}` (`profileName`)" + }, + { + "name": "index_communications_taskId", + "unique": false, + "columnNames": [ + "taskId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `${TABLE_NAME}` (`taskId`)" + } + ], + "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, '69aa2e73e6873cbabeb005dcc45fc7e7')" + ] + } +} \ No newline at end of file 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..d856626a --- /dev/null +++ b/android/src/debug/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + \ 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 c3bc978a..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,13 +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, + 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 f0190ec3..dbfd48d0 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,35 +18,48 @@ package de.gematik.ti.erp.app.debug.ui +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.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding 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.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape 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.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold +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 @@ -56,20 +69,42 @@ 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 com.google.accompanist.insets.navigationBarsPadding +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.OutlinedDebugButton import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall -import java.net.URI +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( @@ -86,7 +121,10 @@ private fun DebugCard( border = null ) { Box { - Column(Modifier.padding(PaddingDefaults.Medium), verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small)) { + Column( + Modifier.padding(PaddingDefaults.Medium), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { Text( title, style = MaterialTheme.typography.h6, @@ -96,7 +134,12 @@ private fun DebugCard( content() } onReset?.run { - IconButton(modifier = Modifier.align(Alignment.TopEnd).padding(PaddingDefaults.Small), onClick = onReset) { + IconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(PaddingDefaults.Small), + onClick = onReset + ) { Icon(Icons.Rounded.Refresh, null) } } @@ -112,7 +155,6 @@ fun EditablePathComponentSetButton( onValueChange: (String, Boolean) -> Unit, onClick: () -> Unit ) { - val color = if (active) Color.Green else Color.Red val buttonText = if (active) "SAVED" else "SET" EditablePathComponentWithControl( @@ -139,7 +181,6 @@ fun TextWithResetButtonComponent( label: String, onClick: () -> Unit, active: Boolean - ) { val color = if (active) Color.Green else Color.Red Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { @@ -190,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) }, @@ -208,158 +247,384 @@ fun EditablePathComponentWithControl( } @Composable -fun DebugScreen(navigation: NavController, viewModel: DebugSettingsViewModel = hiltViewModel()) { - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = "Debug Settings" - ) { navigation.popBackStack() } - } - ) { innerPadding -> +fun DebugScreen( + settingsNavController: NavController +) { + val navController = rememberNavController() + val navMode by navController.navigationModeState(DebugScreenNavigation.DebugMain.path()) - LaunchedEffect(key1 = Unit) { - viewModel.state() + subDI(diBuilder = { + bindProvider { + DebugSettingsViewModel( + visibleDebugTree = instance(), + endpointHelper = instance(), + cardWallUseCase = instance(), + hintUseCase = instance(), + prescriptionUseCase = instance(), + vauRepository = instance(), + idpRepository = instance(), + idpUseCase = instance(), + profilesUseCase = instance(), + featureToggleManager = instance(), + pharmacyDirectRedeemUseCase = instance(), + dispatchers = instance() + ) } + }) { + 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() + } + ) + } + } + } + } +} + +@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( - modifier = Modifier.padding(innerPadding).navigationBarsPadding(), + state = listState, + modifier = Modifier + .padding(innerPadding) + .navigationBarsPadding() + .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), contentPadding = PaddingValues(PaddingDefaults.Medium) ) { item { DebugCard( - title = "General" + title = "Endpoints" ) { - Button( - onClick = { viewModel.restartWithOnboarding() }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Restart with Onboarding") - } - Button( - onClick = { viewModel.refreshPrescriptions() }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Trigger Prescription Refresh") - } - TextWithResetButtonComponent( - label = "UI Hints", - onClick = { viewModel.resetHints() }, - active = false + 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 + } ) } } + 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" + ) + } item { DebugCard( - title = "Card Wall" + title = "Message" ) { - TextWithResetButtonComponent( - label = "Card Wall Intro", - onClick = { viewModel.resetCardWallIntro() }, - active = !viewModel.debugSettingsData.cardWallIntroIsAccepted - ) - - TextWithResetButtonComponent( - label = "Card Access Number", - onClick = { - viewModel.resetCardAccessNumber() - }, - active = !viewModel.debugSettingsData.cardAccessNumberIsSet + OutlinedTextField( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(), + value = message, + label = { Text("Any Message") }, + onValueChange = { + message = it + } ) - - 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" + title = "Certificates" ) { - 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.activeProfileName) + OutlinedTextField( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(), + value = certificates, + label = { Text("Certificate as PEM") }, + onValueChange = { + certificates = it } ) - Button( - onClick = { viewModel.breakSSOToken() }, - modifier = Modifier.fillMaxWidth() + } + } + } + } +} + +@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 + ) + +@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" ) { - Text(text = "Break SSO Token") + Button( + onClick = onClickDirectRedemption, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Direct Redemption") + } + Button( + onClick = { viewModel.refreshPrescriptions() }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Trigger Prescription Refresh") + } + TextWithResetButtonComponent( + label = "UI Hints", + onClick = { viewModel.resetHints() }, + active = false + ) } } - } - item { - DebugCard( - title = "Service URLs" - ) { - EditablePathComponentCheckable( - 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 - ) - ) - } + 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) } + ) } - ) - EditablePathComponentCheckable( - label = "IDP Service Base URL", - textFieldValue = viewModel.debugSettingsData.idpUrl, - checked = viewModel.debugSettingsData.idpActive, - onValueChange = { text, checked -> - runCatching { URI(text) }.getOrNull()?.run { + } + } + item { + DebugCard( + title = "Authentication" + ) { + EditablePathComponentSetButton( + label = "Bearer Token", + text = viewModel.debugSettingsData.bearerToken, + active = viewModel.debugSettingsData.bearerTokenIsSet, + onValueChange = { text, _ -> viewModel.updateState( viewModel.debugSettingsData.copy( - idpUrl = text, - idpActive = checked + bearerToken = text, + bearerTokenIsSet = false ) ) + }, + onClick = { + viewModel.changeBearerToken(viewModel.debugSettingsData.activeProfileId) } + ) + Button( + onClick = { scope.launch { viewModel.breakSSOToken() } }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Break SSO Token") } - ) - - Button( - onClick = { viewModel.saveAndRestartApp() }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Speichern und Neustarten") } } + 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) + } } - item { - VirtualHealthCard(viewModel = viewModel) + } + } +} + +private const val maxNumberOfVisualLogs = 25 + +@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 } - item { - FeatureToggles(viewModel = viewModel) + + 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() + } + + intent.putExtra(Intent.EXTRA_TEXT, Base64.toBase64String(bout.toByteArray())) + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } + }) { + Text("Send Mail") } } + + OutlinedTextField( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(), + value = text, + readOnly = true, + onValueChange = { + text = it + } + ) } } @@ -387,7 +652,10 @@ private fun VirtualHealthCard(modifier: Modifier = Modifier, viewModel: DebugSet val scope = rememberCoroutineScope() OutlinedTextField( - modifier = Modifier.heightIn(max = 144.dp).fillMaxWidth(), + modifier = Modifier + .testTag(TestTag.DebugMenu.CertificateField) + .heightIn(max = 144.dp) + .fillMaxWidth(), value = viewModel.debugSettingsData.virtualHealthCardCert, onValueChange = { viewModel.onSetVirtualHealthCardCertificate(it) @@ -397,10 +665,13 @@ private fun VirtualHealthCard(modifier: Modifier = Modifier, viewModel: DebugSet val subjectInfo = remember(viewModel.debugSettingsData.virtualHealthCardCert) { viewModel.getVirtualHealthCardCertificateSubjectInfo() } - Text(subjectInfo, style = AppTheme.typography.captionl) + Text(subjectInfo, style = AppTheme.typography.caption1l) OutlinedTextField( - modifier = Modifier.heightIn(max = 144.dp).fillMaxWidth(), + modifier = Modifier + .testTag(TestTag.DebugMenu.PrivateKeyField) + .heightIn(max = 144.dp) + .fillMaxWidth(), value = viewModel.debugSettingsData.virtualHealthCardPrivateKey, onValueChange = { viewModel.onSetVirtualHealthCardPrivateKey(it) @@ -409,7 +680,7 @@ private fun VirtualHealthCard(modifier: Modifier = Modifier, viewModel: DebugSet ) Button( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag(TestTag.DebugMenu.SetVirtualHealthCardButton), onClick = { virtualHealthCardLoading = true scope.launch { @@ -464,3 +735,56 @@ private fun FeatureToggles(modifier: Modifier = Modifier, viewModel: DebugSettin } } } + +@Composable +fun EnvironmentSelector( + currentSelectedEnvironment: Environment, + onSelectEnvironment: (environment: Environment) -> Unit, + onSaveEnvironment: () -> Unit +) { + var selectedEnvironment by remember { mutableStateOf(currentSelectedEnvironment) } + + Column( + modifier = Modifier + .navigationBarsPadding() + .fillMaxWidth() + .selectableGroup() + ) { + Text( + text = stringResource(R.string.debug_select_environment), + style = AppTheme.typography.h6, + modifier = Modifier.padding(PaddingDefaults.Medium) + ) + + Environment.values().forEach { + Row( + modifier = Modifier.fillMaxWidth().clickable { + selectedEnvironment = it + onSelectEnvironment(it) + } + ) { + Row( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium, vertical = PaddingDefaults.Small), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + 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()) + } + } +} 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 e0e86bdf..a727db35 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,140 +19,208 @@ 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.BCProvider import de.gematik.ti.erp.app.BuildKonfig import de.gematik.ti.erp.app.DispatchProvider -import de.gematik.ti.erp.app.MainActivity +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 java.math.BigInteger -import java.security.KeyFactory -import java.security.Signature -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, +const val RESTART_DELAY = 1000L + +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, - private val dispatchProvider: DispatchProvider -) : BaseViewModel() { + 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, @@ -163,24 +231,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() } - fun resetCardAccessNumber() = runBlocking { - cardWallUseCase.setCardAccessNumber(null) - updateState(debugSettingsData.copy(cardAccessNumberIsSet = false)) - } - - fun resetCardWallIntro() { - cardWallUseCase.cardWallIntroIsAccepted = false - updateState(debugSettingsData.copy(cardWallIntroIsAccepted = false)) - } + fun getCurrentEnvironment() = endpointHelper.getCurrentEnvironment() fun resetHints() { hintUseCase.resetAllHints() @@ -192,11 +255,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()) } } @@ -212,14 +272,14 @@ 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() { @@ -249,8 +309,10 @@ class DebugSettingsViewModel @Inject constructor( suspend fun onTriggerVirtualHealthCard( certificateBase64: String, privateKeyBase64: String - ) = withContext(dispatchProvider.io()) { + ) = withContext(dispatchers.IO) { idpUseCase.authenticationFlowWithHealthCard( + profileId = profilesUseCase.activeProfileId().first(), + cardAccessNumber = "123123", healthCardCertificate = { Base64.decode(certificateBase64) }, sign = { val curveSpec = ECNamedCurveTable.getParameterSpec("brainpoolP256r1") @@ -265,4 +327,27 @@ class DebugSettingsViewModel @Inject constructor( } ) } + + 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..e15babf4 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,28 @@ 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.theme.AppTheme import de.gematik.ti.erp.app.theme.PaddingDefaults +import java.util.UUID @Composable fun OutlinedDebugButton( @@ -50,3 +74,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..d553143f 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -21,6 +21,7 @@ + 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..477983b9 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,37 @@ 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() - -@HiltAndroidApp -class App : Application() { +class App : Application(), DIAware { + 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..6e6f4f3a 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,77 +18,195 @@ package de.gematik.ti.erp.app +import android.content.Intent import android.content.SharedPreferences +import android.net.Uri 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 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.pharmacy.repository.model.OftenUsedPharmaciesViewModel +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.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import javax.inject.Inject +import io.github.aakira.napier.Napier +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 +import java.net.URI const val SCREENSHOTS_ALLOWED = "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()) } + bindProvider { CardWallNfcPositionViewModel(instance()) } + bindProvider { CardWallViewModel(instance(), instance(), instance()) } + bindProvider { ExternalAuthenticatorListViewModel(instance(), instance()) } + bindProvider { RedeemStateViewModel(instance(), instance()) } +// bindProvider { MessageViewModel(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 { OftenUsedPharmaciesViewModel(instance()) } - @Inject - lateinit var tracker: Tracker + bindSingleton { + SettingsViewModel( + settingsUseCase = instance(), + profilesUseCase = instance(), + profilesWithPairedDevicesUseCase = instance(), + analytics = instance(), + appPrefs = instance(ApplicationPreferencesTag), + dispatchers = instance() + ) + } + bindSingleton { MainViewModel(instance(), instance(), instance(), instance()) } + bindSingleton { MainScreenViewModel(instance(), instance()) } + } - @Inject - @ApplicationPreferences - lateinit var appPrefs: SharedPreferences + private val auth: AuthenticationUseCase by instance() + + private val analytics: Analytics by instance() + + private val appPrefs: SharedPreferences by instance(ApplicationPreferencesTag) + + private var _unvalidatedInstantUri = Channel(Channel.CONFLATED) + var unvalidatedInstantUri = _unvalidatedInstantUri + .receiveAsFlow() + .onEach { + Napier.d("Received new intent: $it") + } 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) + @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?.data?.let { + Napier.d("Received intent: $it") + _unvalidatedInstantUri.send(URI(it.toString())) + } + } + + if (!BuildConfig.DEBUG) { + installMessageConversionExceptionHandler() + } + if (BuildKonfig.INTERNAL) { appPrefs.edit { putBoolean(SCREENSHOTS_ALLOWED, true) @@ -105,49 +223,111 @@ 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) + } - DialogHost { - Box( - if (auth is AuthenticationModeAndMethod.Authenticated) Modifier else noDrawModifier - ) { - MainScreen(navController, mainViewModel) + withDI(di) { + CompositionLocalProvider( + LocalActivity provides this, + LocalAnalytics provides analytics, + LocalAuthenticator provides rememberAuthenticator() + ) { + val authenticator = LocalAuthenticator.current + + 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 + ) - DialogHost { - AnimatedVisibility( - visible = auth is AuthenticationModeAndMethod.AuthenticationRequired, - enter = fadeIn(), - exit = fadeOut() - ) { - UserAuthenticationScreen() + val settingsViewModel by rememberViewModel() + val profileSettingsViewModel by rememberViewModel() + + CompositionLocalProvider( + LocalProfileHandler provides rememberProfileHandler() + ) { + MainScreen( + navController = navController, + mainViewModel = mainViewModel, + settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel + ) + } + } + } + + DialogHost { + AnimatedVisibility( + visible = auth is AuthenticationModeAndMethod.AuthenticationRequired, + enter = fadeIn(), + exit = fadeOut() + ) { + UserAuthenticationScreen() + } } } } + if (BuildConfig.DEBUG && BuildKonfig.DEBUG_VISUAL_TEST_TAGS) { + DebugOverlay(elements) + } } } } } + override fun onUserInteraction() { + super.onUserInteraction() + + auth.resetInactivityTimer() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + lifecycleScope.launch { + intent?.data?.let { + Napier.d("Received intent: $it") + _unvalidatedInstantUri.send(URI(it.toString())) + } + } + } + override fun onResume() { super.onResume() @@ -156,7 +336,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,13 +351,17 @@ class MainActivity : AppCompatActivity() { } } - @OptIn(ExperimentalCoroutinesApi::class) override fun onPause() { super.onPause() NfcAdapter.getDefaultAdapter(applicationContext)?.disableReaderMode(this) } + fun startFastTrackApp(redirect: URI) { + _unvalidatedInstantUri.tryReceive() // clear possible cached values + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(redirect.toString()))) + } + private fun switchScreenshotMode() { // `gemSpec_eRp_FdV A_20203` default settings are not allow screenshots if (appPrefs.getBoolean(SCREENSHOTS_ALLOWED, false)) { 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..1d84e201 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 @@ -23,8 +23,10 @@ import android.os.Bundle import android.os.Parcelable import androidx.navigation.NamedNavArgument import androidx.navigation.NavType -import com.squareup.moshi.Moshi import de.gematik.ti.erp.app.mainscreen.ui.TaskIds +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import javax.annotation.concurrent.Immutable abstract class UriNavType(override val isNullableAllowed: Boolean) : @@ -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..9e766cd1 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/TestTags.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 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}" + } + +// 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 { + object TopNavigation { + val BackButton by tagName() + val CloseButton by tagName() + } + + object Settings { + val SettingsScreen by tagName() + val DebugMenuButton 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 Onboarding { + val Pager by tagName() + + val NextButton by tagName() + + val WelcomeScreen by tagName() + val FeatureScreen by tagName() + val ProfileScreen by tagName() + + object Profile { + val ProfileField 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 TermsOfUseSwitch by tagName() + val OpenTermsOfUseButton by tagName() + val DataProtectionSwitch by tagName() + val OpenDataProtectionButton by tagName() + } + val DataProtectionScreen by tagName() + val TermsOfUseScreen by tagName() + } + + object Main { + val MainScreen by tagName() + val LoginButton by tagName() + object Profile { + val OpenProfileListButton by tagName() + val ProfileDetailsButton 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() + object TokenList { + val TokenScreen by tagName() + val AccessToken by tagName() + val SSOToken by tagName() + val NoTokenHeader by tagName() + val NoTokenInfo 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/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/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..84ed04b0 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/model/UnlockEgkNavigation.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.cardunlock.model + +import de.gematik.ti.erp.app.Route + +object UnlockEgkNavigation { + object Intro : Route("Intro") + object CardAccessNumber : Route("CardAccessNumber") + object PersonalUnblockingKey : Route("PersonalUnblockingKey") + 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..0531fe6a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEGKTroubleshooting.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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 + +@Composable +fun UnlockEGKTroubleshootingPageA( + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + newSecret: String, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit +) { + val dialogState = rememberUnlockEgkDialogState() + UnlockEgkDialog( + dialogState = dialogState, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock + ) + val coroutineScope = rememberCoroutineScope() + TroubleshootingPageAContent( + onBack = onBack, + onNext = onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } + } + ) +} + +@Composable +fun UnlockEGKTroubleshootingPageB( + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + newSecret: String, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit +) { + val dialogState = rememberUnlockEgkDialogState() + UnlockEgkDialog( + dialogState = dialogState, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock + ) + + val coroutineScope = rememberCoroutineScope() + TroubleshootingPageBContent( + onBack, + onNext, + onClickTryMe = { + coroutineScope.launch { dialogState.show() } + } + ) +} + +@Composable +fun UnlockEGKTroubleshootingPageC( + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + newSecret: String, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit, + onNext: () -> Unit, + onBack: () -> Unit +) { + val dialogState = rememberUnlockEgkDialogState() + UnlockEgkDialog( + dialogState = dialogState, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock + ) + + 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..c80cd9da --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgKComponents.kt @@ -0,0 +1,536 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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.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 kotlinx.coroutines.ExperimentalCoroutinesApi +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( + changeSecret: Boolean, + navController: NavController, + onClickLearnMore: () -> Unit +) { + val viewModel by rememberViewModel() + + val unlockNavController = rememberNavController() + var cardAccessNumber by remember { mutableStateOf("") } + var personalUnblockingKey by remember { mutableStateOf("") } + var newSecret by remember { mutableStateOf("") } + + val onRetryCan = { + unlockNavController.navigate(UnlockEgkNavigation.CardAccessNumber.path()) { + popUpTo(UnlockEgkNavigation.CardAccessNumber.path()) { inclusive = true } + } + } + + val onRetryPuk = { + unlockNavController.navigate(UnlockEgkNavigation.PersonalUnblockingKey.path()) { + popUpTo(UnlockEgkNavigation.PersonalUnblockingKey.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(changeSecret = changeSecret) { + unlockNavController.navigate(UnlockEgkNavigation.CardAccessNumber.path()) + } + } + } + composable(UnlockEgkNavigation.CardAccessNumber.route) { + NavigationAnimation { + CardAccessNumberScreen( + changeSecret = changeSecret, + cardAccessNumber = cardAccessNumber, + onCanChanged = { cardAccessNumber = it }, + onClickLearnMore = { onClickLearnMore() }, + onCancel = onClose + ) { + unlockNavController.navigate(UnlockEgkNavigation.PersonalUnblockingKey.path()) + } + } + } + + composable(UnlockEgkNavigation.PersonalUnblockingKey.route) { + NavigationAnimation { + PersonalUnblockingKeyScreen( + changeSecret = changeSecret, + personalUnblockingKey = personalUnblockingKey, + onPersonalUnblockingKeyChanged = { personalUnblockingKey = it }, + onCancel = onClose + ) { + if (changeSecret) { + unlockNavController.navigate(UnlockEgkNavigation.NewSecret.path()) + } else { + unlockNavController.navigate(UnlockEgkNavigation.UnlockEgk.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( + changeSecret = changeSecret, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onBack = onBack, + onClickTroubleshooting = { + unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingPageA.path()) + }, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingPageA.route) { + NavigationAnimation { + UnlockEGKTroubleshootingPageA( + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onNext = { unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingPageB.path()) }, + onBack = onBack + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingPageB.route) { + NavigationAnimation { + UnlockEGKTroubleshootingPageB( + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onNext = { unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingPageC.path()) }, + onBack = onBack + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingPageC.route) { + NavigationAnimation { + UnlockEGKTroubleshootingPageC( + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onClose, + onNext = { unlockNavController.navigate(UnlockEgkNavigation.TroubleshootingNoSuccessPage.path()) }, + onBack = onBack + ) + } + } + + composable(UnlockEgkNavigation.TroubleshootingNoSuccessPage.route) { + NavigationAnimation { + UnlockEGKTroubleshootingNoSuccessPage( + onNext = onClose, + onBack = onBack + ) + } + } + } +} + +@Composable +fun IntroScreen(changeSecret: Boolean, onNext: () -> Unit) { + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("unlockEgk/unlock"), + title = if (changeSecret) { + stringResource(R.string.unlock_egk_top_bar_title_change_secret) + } else { + stringResource(R.string.unlock_egk_top_bar_title) + }, + onNext = { onNext() }, + nextText = stringResource(R.string.unlock_egk_next), + listState = lazyListState + ) { + UnlockIntroContent(lazyListState = lazyListState) + } +} + +@Composable +fun UnlockIntroContent( + 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)) + SimpleCheck(stringResource(R.string.unlock_egk_intro_puk)) + SpacerSmall() + Text( + text = stringResource(R.string.unlock_egk_puk_info), + style = AppTheme.typography.caption1l + ) + } + } +} + +@Composable +fun CardAccessNumberScreen( + changeSecret: Boolean, + cardAccessNumber: String, + onCanChanged: (String) -> Unit, + onClickLearnMore: () -> Unit, + onCancel: () -> Unit, + onNext: () -> Unit +) { + CardAccessNumber( + onClickLearnMore = onClickLearnMore, + can = cardAccessNumber, + screenTitle = if (changeSecret) { + stringResource(R.string.unlock_egk_top_bar_title_change_secret) + } 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 +fun PersonalUnblockingKeyScreen( + changeSecret: Boolean, + personalUnblockingKey: String, + onPersonalUnblockingKeyChanged: (String) -> Unit, + onCancel: () -> Unit, + onNext: (String) -> Unit +) { + PukScreen( + changeSecret = changeSecret, + navMode = NavigationMode.Back, + secret = personalUnblockingKey, + secretRange = PUKLengthRange, + onSecretChange = onPersonalUnblockingKeyChanged, + onCancel = onCancel, + next = onNext, + nextText = stringResource(R.string.unlock_egk_next) + ) +} + +@Composable +fun PukScreen( + changeSecret: Boolean, + 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 (changeSecret) { + stringResource(R.string.unlock_egk_top_bar_title_change_secret) + } 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 +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() + } + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun UnlockScreen( + changeSecret: Boolean, + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + newSecret: String, + onClickTroubleshooting: () -> Unit, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit, + onBack: () -> Unit, + onFinishUnlock: () -> Unit +) { + val nfcPositionViewModel by rememberViewModel() + val state by remember { mutableStateOf(nfcPositionViewModel.screenState()) } + val dialogState = rememberUnlockEgkDialogState() + + UnlockEgkDialog( + changeSecret = changeSecret, + dialogState = dialogState, + viewModel = viewModel, + cardAccessNumber = cardAccessNumber, + personalUnblockingKey = personalUnblockingKey, + troubleShootingEnabled = true, + onClickTroubleshooting = onClickTroubleshooting, + newSecret = newSecret, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk, + onFinishUnlock = onFinishUnlock + ) + + 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..d70b5599 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkDialog.kt @@ -0,0 +1,454 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.NfcAdapter +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.LocalContext +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 de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.R +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.rotatingScanCardAssistance +import de.gematik.ti.erp.app.cardwall.ui.toAnnotatedString +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.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.retry +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import io.github.aakira.napier.Napier +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() } +} + +@ExperimentalCoroutinesApi +@Composable +fun UnlockEgkDialog( + changeSecret: Boolean = false, + dialogState: UnlockEgkDialogState, + viewModel: UnlockEgkViewModel, + cardAccessNumber: String, + personalUnblockingKey: String, + newSecret: String, + onClickTroubleshooting: (() -> Unit)? = null, + troubleShootingEnabled: Boolean = false, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit, + onFinishUnlock: () -> Unit +) { + val activity = LocalActivity.current as MainActivity + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val toggleUnlock = dialogState.toggleUnlock + val nfcEnabled by produceState(initialValue = false) { + value = NfcAdapter.getDefaultAdapter(context).isEnabled + } + + var showEnableNfcDialog by remember(nfcEnabled) { mutableStateOf(!nfcEnabled) } + 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 && !nfcEnabled) { + showEnableNfcDialog = true + value = UnlockEgkState.None + } else if (it.value) { + showCardCommunicationDialog = true + emitAll( + viewModel.unlockEgk( + changeSecret = changeSecret, + can = cardAccessNumber, + puk = personalUnblockingKey, + newSecret = newSecret, + tag = activity.nfcTagFlow + ) + ) + } 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( + changeSecret = changeSecret, + can = cardAccessNumber, + puk = personalUnblockingKey, + 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.retry() + .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(activity) { + showEnableNfcDialog = false + } + } + + val nextText = nextTextFromUnlockEgkState(state) + + val resumeText = resumeTextFromUnlockEgkState(changeSecret, state) + + resumeText?.let { + ResumeDialog( + state = state, + resumeText = it, + onFinishUnlock = onFinishUnlock, + nextText = nextText, + onToggleUnlock = { + coroutineScope.launch { + toggleUnlock.emit(ToggleUnlock.ToggleByUser(it)) + } + }, + onRetryCan = onRetryCan, + onRetryPuk = onRetryPuk + ) + } +} + +@Composable +private fun nextTextFromUnlockEgkState(state: UnlockEgkState): String { + val nextText = when (state) { + UnlockEgkState.HealthCardCardAccessNumberWrong, + UnlockEgkState.HealthCardPukRetriesLeft -> stringResource(R.string.cdw_auth_retry_pin_can) + UnlockEgkState.HealthCardCommunicationFinished, + UnlockEgkState.HealthCardPukBlocked -> stringResource(R.string.unlock_egk_finished_ok) + else -> stringResource(R.string.cdw_auth_retry) + } + return nextText +} + +@Composable +private fun resumeTextFromUnlockEgkState( + changeSecret: Boolean, + state: UnlockEgkState +): Pair? { + val resumeText = when (state) { + UnlockEgkState.HealthCardCommunicationFinished -> Pair( + if (changeSecret) { + stringResource(R.string.unlock_egk_dialog_new_secret_saved).toAnnotatedString() + } else { + stringResource(R.string.unlock_egk_unlock_success_header).toAnnotatedString() + }, + 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.pukRetriesLeft) + ) + UnlockEgkState.HealthCardPukBlocked -> Pair( + if (changeSecret) { + stringResource(R.string.unlock_egk_dialog_saving_new_secret_not_possible).toAnnotatedString() + } else { + stringResource(R.string.unlock_not_possible_header).toAnnotatedString() + }, + stringResource(R.string.unlock_not_possible_info).toAnnotatedString() + ) + else -> null + } + return resumeText +} + +@Composable +private fun ResumeDialog( + state: UnlockEgkState, + resumeText: Pair, + onFinishUnlock: () -> Unit, + nextText: String, + onToggleUnlock: (Boolean) -> Unit, + onRetryCan: () -> Unit, + onRetryPuk: () -> Unit +) { + if (state == UnlockEgkState.HealthCardCommunicationFinished) { + 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 = { + onToggleUnlock(false) + }, + onRetry = { + when (state) { + UnlockEgkState.HealthCardCardAccessNumberWrong -> onRetryCan() + UnlockEgkState.HealthCardPukRetriesLeft -> onRetryPuk() + UnlockEgkState.HealthCardPukBlocked -> onFinishUnlock() + 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..da409e93 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/ui/UnlockEgkViewModel.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 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( + changeSecret: Boolean, + can: String, + puk: String, + newSecret: String, + tag: Flow + ): Flow { + val cardChannel = tag.map { NfcHealthCard.connect(it) } + return unlockEgkUseCase.unlockEgk( + changeSecret = changeSecret, + can = can, + puk = puk, + 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..4d475529 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardunlock/usecase/UnlockEgkUseCase.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.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.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, + HealthCardPukBlocked, + HealthCardCommunicationTrustedChannelEstablished, + HealthCardCommunicationFinished; + + var pukRetriesLeft: Int = -1 + + @Stable + fun isFailure() = + when (this) { + HealthCardPukRetriesLeft, + HealthCardCardAccessNumberWrong, + HealthCardCommunicationInterrupted, + HealthCardPukBlocked -> true + else -> false + } + + @Stable + fun isInProgress() = + when (this) { + HealthCardCommunicationChannelReady, + HealthCardCommunicationTrustedChannelEstablished -> true + else -> false + } + + @Stable + fun isReady() = this == None +} + +class UnlockEgkUseCase { + fun unlockEgk( + changeSecret: Boolean, + can: String, + puk: String, + newSecret: String, + cardChannel: Flow + ): Flow = + channelFlow { + send(UnlockEgkState.UnlockFlowInitialized) + cardChannel.first().use { nfcChannel -> + send(UnlockEgkState.HealthCardCommunicationChannelReady) + try { + healthCardCommunication(changeSecret, nfcChannel, can, puk, newSecret) + } catch (expected: Exception) { + val state = handleException(expected) + send(state) + } + } + } + + private fun handleException(e: Throwable): UnlockEgkState = + when (e) { + is ResponseException -> { + @Suppress("MagicNumber") + when (e.responseStatus) { + ResponseStatus.AUTHENTICATION_FAILURE -> UnlockEgkState.HealthCardCardAccessNumberWrong + ResponseStatus.PUK_BLOCKED -> UnlockEgkState.HealthCardPukBlocked + ResponseStatus.WRONG_SECRET_WARNING_COUNT_09 -> retriesLeft(9) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_08 -> retriesLeft(8) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_07 -> retriesLeft(7) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_06 -> retriesLeft(6) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_05 -> retriesLeft(5) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_04 -> retriesLeft(4) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> retriesLeft(3) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> retriesLeft(2) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> retriesLeft(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( + changeSecret: Boolean, + channel: NfcCardChannel, + can: String, + puk: String, + newSecret: String +) { + val paceKey = channel.establishTrustedChannel(can) + + val secChannel = NfcCardSecureChannel( + channel.isExtendedLengthSupported, + channel.card, + paceKey + ) + + send(UnlockEgkState.HealthCardCommunicationTrustedChannelEstablished) + + val response = secChannel.unlockEgk( + changeSecret = changeSecret, + puk = puk, + newSecret = newSecret + ) + + println("Response: $response") + + send( + @Suppress("MagicNumber") + + when (response) { + ResponseStatus.SUCCESS -> UnlockEgkState.HealthCardCommunicationFinished + ResponseStatus.WRONG_SECRET_WARNING_COUNT_09 -> retriesLeft(9) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_08 -> retriesLeft(8) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_07 -> retriesLeft(7) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_06 -> retriesLeft(6) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_05 -> retriesLeft(5) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_04 -> retriesLeft(4) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_03 -> retriesLeft(3) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_02 -> retriesLeft(2) + ResponseStatus.WRONG_SECRET_WARNING_COUNT_01 -> retriesLeft(1) + else -> UnlockEgkState.HealthCardPukBlocked + } + ) +} + +private fun retriesLeft(n: Int) = + UnlockEgkState.HealthCardPukRetriesLeft.apply { + this.pukRetriesLeft = 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..da3cbfa6 --- /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()) } +} 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..e7eca985 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/Authentication.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.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.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonDefaults +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.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.cardwall.usecase.AuthenticationState +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.SpacerLarge +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 + + class HealthCard(val can: String) : InitialAuthenticationData + object SecureElement : InitialAuthenticationData + data class External(val authenticatorId: String, val authenticatorName: String) : InitialAuthenticationData + object None : 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 +} + +@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) + AuthenticationBridge.SecureElement -> + authenticatorSecureElement.authenticate(profileId, PromptAuthenticator.AuthScope.Prescriptions) + is AuthenticationBridge.External -> + authenticatorExternal.authenticate(profileId, PromptAuthenticator.AuthScope.Prescriptions) + 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) + AuthenticationBridge.SecureElement -> + authenticatorSecureElement.authenticate(profileId, PromptAuthenticator.AuthScope.PairedDevices) + is AuthenticationBridge.External -> + authenticatorExternal.authenticate(profileId, PromptAuthenticator.AuthScope.PairedDevices) + AuthenticationBridge.None -> flowOf(PromptAuthenticator.AuthResult.NoneEnrolled) + } + ) + } + + suspend fun cancelAllAuthentications() { + authenticatorSecureElement.cancelAuthentication() + authenticatorHealthCard.cancelAuthentication() + } +} + +@Composable +fun PromptScaffold( + title: String, + onCancel: () -> Unit, + content: @Composable () -> Unit +) { + Surface( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(PaddingDefaults.Medium), + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(16.dp), + elevation = 8.dp + ) { + val top = ButtonDefaults.TextButtonContentPadding.calculateTopPadding() + Column( + Modifier + .padding(top = PaddingDefaults.Medium - top, bottom = PaddingDefaults.Medium) + ) { + Row( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + title, + style = AppTheme.typography.h6, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cdw_nfc_dlg_cancel)) + } + } + SpacerLarge() + content() + } + } +} + +@Composable +fun rememberAuthenticator(): Authenticator { + val bridge by rememberViewModel() + val promptSE = rememberSecureHardwarePromptAuthenticator(bridge) + val promptHC = rememberHealthCardPromptAuthenticator(bridge) + val promptEX = rememberExternalPromptAuthenticator(bridge) + 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..8373c106 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/ExternalAuthPrompt.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.platform.LocalContext +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.MainActivity +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.ui.PrimaryButtonSmall +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.Dialog +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.receiveAsFlow +import kotlinx.coroutines.launch + +@Stable +class ExternalPromptAuthenticator( + private val activity: MainActivity, + 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) + + override fun authenticate( + profileId: ProfileIdentifier, + scope: PromptAuthenticator.AuthScope + ): Flow = channelFlow { + when (val authFor = bridge.authenticateFor(profileId)) { + is AuthenticationBridge.External -> { + state = State.SelectInsurance(authFor.authenticatorName) + + 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 -> + activity.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 = activity.unvalidatedInstantUri.first() + + Napier.d("doExternalAuthorization for $uri") + + bridge.doExternalAuthorization(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) + } + } + }.onCompletion { + state = State.None + } + + 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 + + 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), + 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 +): ExternalPromptAuthenticator { + val activity = LocalContext.current as MainActivity + return remember { + ExternalPromptAuthenticator(activity, 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..92b79945 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/HealthCardPrompt.kt @@ -0,0 +1,578 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.PrimaryButton +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.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 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 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 + + 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 + } + + 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 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), + 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..4573c9c9 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/MiniCardWallViewModel.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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 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 = + when (val ssoTokenScope = useCase.authenticationData(profileId).first().singleSignOnTokenScope) { + is IdpData.ExternalAuthenticationToken -> AuthenticationBridge.External( + authenticatorId = ssoTokenScope.authenticatorId, + authenticatorName = ssoTokenScope.authenticatorName + ) + is IdpData.AlternateAuthenticationToken, + is IdpData.AlternateAuthenticationWithoutToken -> AuthenticationBridge.SecureElement + is IdpData.DefaultToken -> AuthenticationBridge.HealthCard(can = ssoTokenScope.cardAccessNumber) + null -> AuthenticationBridge.None + } + + 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) + } + } +} 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..a54a3e89 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/mini/ui/SecureHardwarePrompt.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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.profiles.repository.ProfileIdentifier +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) + + 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() -> + 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) + } +} 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/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/command/VerifyCommand.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/VerifyCommand.kt deleted file mode 100644 index 43d20667..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/VerifyCommand.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.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 - */ - -private const val CLA = 0x00 -private const val INS = 0x20 -private const val MODE_VERIFICATION_DATA = 0x00 - -/** - * Use case Change Password Secret (Pin) gemSpec_COS#14.6.6.1 - */ -fun HealthCardCommand.Companion.verifyPin( - password: Password, - dfSpecific: Boolean, - pin: EncryptedPinFormat2 -) = - HealthCardCommand( - expectedStatus = verifyStatus, - cla = CLA, - ins = INS, - p1 = MODE_VERIFICATION_DATA, - p2 = password.calculateKeyReference(dfSpecific), - data = pin.bytes - ) 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/CardWallAccessNumber.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAccessNumber.kt new file mode 100644 index 00000000..f915c174 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallAccessNumber.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.cardwall.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.layout.size +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.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +import androidx.compose.material.TextButton +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.shadow +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +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.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.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)) + } + } + + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize().padding(it), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + item { + HealthCardCanImage() + } + item { + CanDescription(onClickLearnMore) + SpacerXXLarge() + CanInputField( + modifier = Modifier + .scrollOnFocus(), + can = can, + onCanChange = onCanChange, + next = onNext + ) + println("asdfadsfgsdgsdf") + } + } + } +} + +@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 +) { + Column(modifier) { + 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 == EXPECTED_CAN_LENGTH) { + next() + } + } + ), + singleLine = true, + modifier = Modifier + .testTag(TestTag.CardWall.CAN.CANField) + .fillMaxWidth() + .padding(start = PaddingDefaults.Large, bottom = PaddingDefaults.Small, end = PaddingDefaults.Large) + .onFocusChanged { + isFocussed = it.isFocused + } + ) { + 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(EXPECTED_CAN_LENGTH) { + 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 = AppTheme.typography.h6, + modifier = Modifier + .align(Alignment.Center) + .clearAndSetSemantics { } + ) + } + } + } + } + } + } +} 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 50e68705..799849e2 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,25 +86,24 @@ 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 de.gematik.ti.erp.app.MainActivity import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag 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.core.LocalActivity -import de.gematik.ti.erp.app.core.LocalTracker +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.tracking.Tracker -import de.gematik.ti.erp.app.tracking.Tracker.AuthenticationProblem +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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -117,7 +115,10 @@ 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 java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException private enum class HealthCardAnimationState { START, @@ -142,7 +143,6 @@ fun rememberCardWallAuthenticationDialogState(): CardWallAuthenticationDialogSta } @OptIn( - ExperimentalMaterialApi::class, ExperimentalAnimationApi::class, ExperimentalCoroutinesApi::class ) @@ -151,6 +151,7 @@ fun CardWallAuthenticationDialog( dialogState: CardWallAuthenticationDialogState = rememberCardWallAuthenticationDialogState(), viewModel: CardWallViewModel, authenticationMethod: CardWallData.AuthenticationMethod, + profileId: ProfileIdentifier, cardAccessNumber: String, personalIdentificationNumber: String, troubleShootingEnabled: Boolean = false, @@ -158,6 +159,7 @@ fun CardWallAuthenticationDialog( onFinal: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit, onClickTroubleshooting: (() -> Unit)? = null, onStateChange: ((AuthenticationState) -> Unit)? = null ) { @@ -166,10 +168,10 @@ fun CardWallAuthenticationDialog( val toggleAuth = dialogState.toggleAuth // TODO: `viewModel.isNFCEnabled()` is not a proper key. Maybe find a better way to re-trigger this - var showEnableNfcDialog by remember(viewModel.isNFCEnabled()) { mutableStateOf(!viewModel.isNFCEnabled()) } + var showEnableNfcDialog by remember { mutableStateOf(!viewModel.isNFCEnabled()) } var errorCount by remember(troubleShootingEnabled) { mutableStateOf(0) } - val tracker = LocalTracker.current + val tracker = LocalAnalytics.current val state by produceState(initialValue = AuthenticationState.None) { toggleAuth.transformLatest { @@ -182,6 +184,7 @@ fun CardWallAuthenticationDialog( } else if (it.value) { emitAll( viewModel.doAuthentication( + profileId = profileId, can = cardAccessNumber, pin = personalIdentificationNumber, method = authenticationMethod, @@ -194,7 +197,7 @@ fun CardWallAuthenticationDialog( } is ToggleAuth.ToggleByHealthCard -> { val collectedOnce = AtomicBoolean(false) - val f = flow { + val tagFlow = flow { if (collectedOnce.get()) { activity.nfcTagFlow.collect { emit(it) @@ -206,16 +209,17 @@ fun CardWallAuthenticationDialog( } emitAll( viewModel.doAuthentication( + profileId = profileId, can = cardAccessNumber, pin = personalIdentificationNumber, method = authenticationMethod, - f + 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) @@ -274,6 +278,7 @@ 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) } @@ -303,8 +308,8 @@ 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( @@ -320,21 +325,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)) - } - ) + EnableNfcDialog(activity) { + showEnableNfcDialog = false + } } retryText?.let { @@ -350,6 +343,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)) @@ -362,12 +356,31 @@ fun CardWallAuthenticationDialog( } @Composable -private fun ErrorDialog( +fun EnableNfcDialog(activity: MainActivity, onCancel: () -> Unit) { + 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 = { + activity.startActivity(Intent("android.settings.NFC_SETTINGS").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + ) +} + +@Composable +fun ErrorDialog( header: AnnotatedString, info: AnnotatedString, retryButtonText: String, onCancel: () -> Unit, - onRetry: () -> Unit, + onRetry: () -> Unit ) = CommonAlertDialog( header = header, @@ -378,11 +391,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, @@ -403,6 +416,7 @@ private fun AuthenticationDialog( ) { Box( Modifier + .testTag(TestTag.CardWall.Nfc.CardReadingDialog) .semantics(false) { } .fillMaxSize() .background(SolidColor(Color.Black), alpha = 0.5f) @@ -442,34 +456,10 @@ private fun AuthenticationDialog( 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() - ) { - when (screen) { - 0 -> SearchingCardAnimation() - 1 -> ReadingCardAnimation() - 2 -> TagLostCard() - } - } + 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()) } @@ -525,13 +515,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() ) @@ -543,19 +533,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() @@ -567,17 +557,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) @@ -602,10 +591,11 @@ private fun SearchingCardAnimation() { DpOffset.VectorConverter, transitionSpec = { tween( - healthCardOffsetDuration, + healthCardOffsetDuration - 10, 0 ) - } + }, + label = "healthCardOffset" ) { state -> when (state) { HealthCardAnimationState.START -> DpOffset(0.dp, 0.dp) @@ -622,7 +612,8 @@ private fun SearchingCardAnimation() { 1000, 0 ) - } + }, + label = "healthCardScale" ) { state -> when (state) { HealthCardAnimationState.START -> 1.0f @@ -635,7 +626,8 @@ private fun SearchingCardAnimation() { 1300, 1500 ) - } + }, + label = "smartPhoneAlpha" ) { state -> when (state) { true -> 1.0f @@ -649,7 +641,8 @@ private fun SearchingCardAnimation() { 1300, 1500 ) - } + }, + label = "smartPhoneOffset" ) { state -> when (state) { true -> 0.dp @@ -679,7 +672,8 @@ private fun SearchingCardAnimation() { Triple( it, wobbleTransition.animateFloat( - 1.0f, 1.1f, + 1.0f, + 1.1f, animationSpec = infiniteRepeatable( animation = keyframes { durationMillis = 2500 @@ -692,7 +686,8 @@ private fun SearchingCardAnimation() { ) ), wobbleTransition.animateFloat( - 1.0f, 0.7f, + 1.0f, + 0.7f, animationSpec = infiniteRepeatable( animation = tween( durationMillis = 1000, @@ -732,7 +727,9 @@ private fun SearchingCardAnimation() { ) Image( - smartPhone, null, alpha = smartPhoneAlpha, + smartPhone, + null, + alpha = smartPhoneAlpha, modifier = Modifier .size(80.dp) .align( @@ -745,7 +742,7 @@ private fun SearchingCardAnimation() { } @Composable -private fun ReadingCardAnimation() { +fun ReadingCardAnimation() { Box { Image( painterResource(R.drawable.ic_healthcard_spinner), @@ -767,14 +764,14 @@ private fun ReadingCardAnimation() { } @Composable -private fun TagLostCard() { +fun TagLostCard() { Image( painterResource(R.drawable.ic_healthcard_tag_lost), - null, + null ) } -private fun Tracker.trackAuth(state: AuthenticationState) { +private fun Analytics.trackAuth(state: AuthenticationState) { if (trackingAllowed.value) { when (state) { AuthenticationState.HealthCardBlocked -> @@ -794,7 +791,41 @@ private fun Tracker.trackAuth(state: AuthenticationState) { 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 index 64de2ac5..2c127ae6 100644 --- 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 @@ -44,7 +44,7 @@ fun SecondaryButton( enabled: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), - shape: Shape = RoundedCornerShape(16.dp), + shape: Shape = RoundedCornerShape(8.dp), border: BorderStroke? = null, colors: ButtonColors = ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral100, @@ -69,6 +69,60 @@ fun SecondaryButton( 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 PrimaryButton( onClick: () -> Unit, @@ -76,12 +130,12 @@ fun PrimaryButton( enabled: Boolean = true, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, elevation: ButtonElevation? = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 4.dp), - shape: Shape = RoundedCornerShape(16.dp), + shape: Shape = RoundedCornerShape(8.dp), border: BorderStroke? = null, colors: ButtonColors = ButtonDefaults.buttonColors(), contentPadding: PaddingValues = PaddingValues( horizontal = PaddingDefaults.Medium, - vertical = 12.dp + vertical = 7.dp ), content: @Composable RowScope.() -> Unit ) = 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 927dcedb..66007b89 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,148 +18,109 @@ package de.gematik.ti.erp.app.cardwall.ui +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.ExperimentalLayoutApi 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.imePadding 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.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBarsPadding import de.gematik.ti.erp.app.R +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.CardWallData 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.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.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.launch -import java.util.Locale +import org.kodein.di.compose.rememberViewModel -private val framePadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 24.dp) - -@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 = rememberNavController() - val fastTrackOn by produceState(initialValue = false) { - viewModel.fastTrackOn().collect { - value = it - } - } + val state by viewModel.state(profileId).collectAsState(viewModel.defaultState) val startDestination = when { - fastTrackOn -> CardWallNavigation.Switch.path() - canAvailable -> CardWallNavigation.PersonalIdentificationNumber.path() - state.isIntroSeenByUser -> CardWallNavigation.CardAccessNumber.path() + state.cardAccessNumber.isNotBlank() -> CardWallNavigation.PersonalIdentificationNumber.path() state.hardwareRequirementsFulfilled -> CardWallNavigation.Intro.path() else -> CardWallNavigation.MissingCapabilities.path() } @@ -183,6 +144,7 @@ fun CardWallScreen( popUpTo(CardWallNavigation.CardAccessNumber.path()) { inclusive = true } } } + val onRetryPin = { navController.navigate(CardWallNavigation.PersonalIdentificationNumber.path()) { popUpTo(CardWallNavigation.PersonalIdentificationNumber.path()) { @@ -190,100 +152,93 @@ 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 rememberSaveable(state.cardAccessNumber) { mutableStateOf(state.cardAccessNumber) } + var personalIdentificationNumber by rememberSaveable(state.personalIdentificationNumber) { mutableStateOf(state.personalIdentificationNumber) } + var selectedAuthenticationMethod by rememberSaveable(state.selectedAuthenticationMethod) { mutableStateOf(state.selectedAuthenticationMethod) } + 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 = 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) + selectedAuthenticationMethod = CardWallData.AuthenticationMethod.HealthCard navController.navigate(CardWallNavigation.Authentication.path()) } } @@ -293,10 +248,15 @@ fun CardWallScreen( composable(CardWallNavigation.AuthenticationSelection.route) { NavigationAnimation(mode = navigationMode) { AuthenticationSelection( - state.demoMode, - biometricMode = biometricMode, - selectedAuthMode = state.selectedAuthenticationMethod, - onSelectAuthMode = { viewModel.onSelectAuthenticationMethod(it) } + selectedAuthMode = selectedAuthenticationMethod, + onSelectAuthMode = { selectedAuthenticationMethod = it }, + onSelectAlternativeOption = { + navController.navigate( + CardWallNavigation.AlternativeOption.path() + ) + }, + onCancel = { onResumeCardWall() }, + onBack = onBack ) { navController.navigate( CardWallNavigation.Authentication.path() @@ -305,15 +265,35 @@ fun CardWallScreen( } } + composable(CardWallNavigation.AlternativeOption.route) { + NavigationAnimation(mode = navigationMode) { + AlternativeOptionInfoScreen( + onCancel = { + selectedAuthenticationMethod = CardWallData.AuthenticationMethod.None + onBack() + }, + onAccept = { + navController.navigate( + CardWallNavigation.Authentication.path(), + navOptions = navOptions { + popUpTo(CardWallNavigation.AuthenticationSelection.route) + } + ) + } + ) + } + } + composable(CardWallNavigation.Authentication.route) { NavigationAnimation(mode = navigationMode) { - Authentication( - viewModel, state.demoMode, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + CardWallNfcInstructionScreen( + viewModel = viewModel, + profileId = state.activeProfileId, + authenticationMethod = selectedAuthenticationMethod, + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber, onNext = { - onFinishedCardWall() + onResumeCardWall() }, onRetryCan = { navController.navigate(CardWallNavigation.CardAccessNumber.path()) { @@ -327,31 +307,40 @@ fun CardWallScreen( } } }, + onUnlockEgk = onUnlockEgk, onClickTroubleshooting = { navController.navigate(CardWallNavigation.TroubleshootingPageA.path()) - } + }, + onBack = onBack ) } } composable(CardWallNavigation.ExternalAuthenticator.route) { NavigationAnimation(mode = navigationMode) { - ExternalAuthenticatorListScreen(navController) + ExternalAuthenticatorListScreen( + profileId = state.activeProfileId, + onNext = onResumeCardWall, + onCancel = { onResumeCardWall() }, + onBack = onBack + ) } } composable(CardWallNavigation.TroubleshootingPageA.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingPageA( + profileId = state.activeProfileId, viewModel = viewModel, - onFinal = onFinishedCardWall, + onFinal = onResumeCardWall, onBack = onBack, onNext = { navController.navigate(CardWallNavigation.TroubleshootingPageB.path()) }, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + authenticationMethod = selectedAuthenticationMethod, + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) } } @@ -359,15 +348,17 @@ fun CardWallScreen( composable(CardWallNavigation.TroubleshootingPageB.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingPageB( + profileId = state.activeProfileId, viewModel = viewModel, - onFinal = onFinishedCardWall, + onFinal = onResumeCardWall, onBack = onBack, onNext = { navController.navigate(CardWallNavigation.TroubleshootingPageC.path()) }, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + authenticationMethod = selectedAuthenticationMethod, + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) } } @@ -375,15 +366,17 @@ fun CardWallScreen( composable(CardWallNavigation.TroubleshootingPageC.route) { NavigationAnimation(mode = navigationMode) { CardWallTroubleshootingPageC( + profileId = state.activeProfileId, viewModel = viewModel, - onFinal = onFinishedCardWall, + onFinal = onResumeCardWall, onBack = onBack, onNext = { navController.navigate(CardWallNavigation.TroubleshootingNoSuccessPage.path()) }, - authenticationMethod = state.selectedAuthenticationMethod, - cardAccessNumber = state.cardAccessNumber, - personalIdentificationNumber = state.personalIdentificationNumber, + authenticationMethod = selectedAuthenticationMethod, + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber, onRetryCan = onRetryCan, - onRetryPin = onRetryPin + onRetryPin = onRetryPin, + onUnlockEgk = onUnlockEgk ) } } @@ -391,402 +384,211 @@ 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( + changeSecret = false, + 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 ) { - - 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() - } - } + 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 + ) } -@OptIn(ExperimentalComposeUiApi::class) +@Suppress("ComplexMethod") @Composable -private fun PersonalIdentificationNumber( - navMode: NavigationMode, - pin: String, - demoMode: Boolean, - onPinChange: (String) -> Unit, - next: (String) -> Unit +private fun AuthenticationSelection( + selectedAuthMode: CardWallData.AuthenticationMethod, + onSelectAuthMode: (CardWallData.AuthenticationMethod) -> Unit, + onSelectAlternativeOption: () -> Unit, + onCancel: () -> Unit, + onBack: () -> Unit, + onNext: () -> Unit ) { + 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) } - CardWallScaffold( - modifier = Modifier.testTag("cardWall/personalIdentificationNumber"), - backMode = when (navMode) { - NavigationMode.Forward, - NavigationMode.Back, - NavigationMode.Closed -> NavigationBarMode.Back - NavigationMode.Open -> NavigationBarMode.Close - }, + val enrollBiometricIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent(Settings.ACTION_BIOMETRIC_ENROLL) + } else { + Intent(Settings.ACTION_APPLICATION_SETTINGS) + } + + val lazyListState = rememberLazyListState() + CardHandlingScaffold( + modifier = Modifier.testTag("cardWall/authenticationSelection"), title = stringResource(R.string.cdw_top_bar_title), - nextEnabled = pin.length in 6..8, - onNext = { next(pin) }, - demoMode = demoMode + nextEnabled = selectedAuthMode != CardWallData.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() - Text( - stringResource(R.string.cdw_pin_title), - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(PaddingDefaults.Medium) - ) - - Column { - val textValue = TextFieldValue( - annotatedString = buildAnnotatedString { - pushTtsAnnotation(VerbatimTtsAnnotation(pin)) - append(pin) - pop() - }, - selection = TextRange(pin.length) - ) - - var isFocussed by remember { mutableStateOf(false) } - val pinRegex = """^\d{0,8}$""".toRegex() - - 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 == CardWallData.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 { + onSelectAuthMode(CardWallData.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)) + SpacerMedium() - Text( - text = transformedPin, - style = MaterialTheme.typography.h6.copy(letterSpacing = 0.7.em), - textAlign = TextAlign.Center, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - .clearAndSetSemantics { } - ) - - 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 == CardWallData.AuthenticationMethod.HealthCard, + startIcon = Icons.Rounded.Close, + text = stringResource(R.string.cdw_selection_save_not) + ) { + onSelectAuthMode(CardWallData.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 ) - 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_first), + style = AppTheme.typography.body1 + ) + 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() } } @@ -795,23 +597,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 ) } } @@ -819,14 +607,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 { @@ -839,170 +624,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 { @@ -1011,137 +677,118 @@ 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.imePadding(), + topBarColor = MaterialTheme.colors.surface, + listState = listState, + content = content + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@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( + val isKeyboardVisible = WindowInsets.isImeVisible + + Column( + Modifier + .then(if (isKeyboardVisible) Modifier.navigationBarsPadding() else Modifier) + .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 index d38f8413..2817cd5f 100644 --- 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 @@ -30,7 +30,6 @@ 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 @@ -55,6 +54,7 @@ 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.theme.AppTheme import de.gematik.ti.erp.app.utils.compose.Spacer16 private val captionLanguageMapping = listOf( @@ -159,7 +159,7 @@ fun InstructionVideo() { .wrapContentHeight() .fillMaxWidth() ) { - Text(caption, style = MaterialTheme.typography.subtitle1, textAlign = TextAlign.Center) + Text(caption, style = AppTheme.typography.subtitle1, textAlign = TextAlign.Center) } } } 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..f4b837d1 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallNfcInstructionScreen.kt @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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.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 androidx.compose.foundation.layout.systemBarsPadding +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import de.gematik.ti.erp.app.MainActivity +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.cardwall.ui.model.CardWallData +import de.gematik.ti.erp.app.cardwall.ui.model.CardWallNfcPositionViewModelData +import de.gematik.ti.erp.app.core.LocalActivity +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 + +@Composable +fun CardWallNfcInstructionScreen( + onClickTroubleshooting: () -> Unit, + onBack: () -> Unit, + viewModel: CardWallViewModel, + authenticationMethod: CardWallData.AuthenticationMethod, + profileId: ProfileIdentifier, + cardAccessNumber: String, + personalIdentificationNumber: String, + onNext: () -> Unit, + onUnlockEgk: () -> Unit, + onRetryCan: () -> Unit, + onRetryPin: () -> Unit +) { + val nfcPositionViewModel by rememberViewModel() + val state by remember { mutableStateOf(nfcPositionViewModel.screenState()) } + + val dialogState = rememberCardWallAuthenticationDialogState() + val activity = LocalActivity.current as MainActivity + + if (!viewModel.isNFCEnabled()) { + EnableNfcDialog(activity) { + onBack() + } + } else { + CardWallAuthenticationDialog( + dialogState = dialogState, + viewModel = viewModel, + authenticationMethod = authenticationMethod, + profileId = profileId, + cardAccessNumber = cardAccessNumber, + personalIdentificationNumber = personalIdentificationNumber, + 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 + .fillMaxSize() + .systemBarsPadding() + .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..15e3fa5b 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,107 @@ package de.gematik.ti.erp.app.cardwall.ui -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.clickable 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.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.outlined.Info +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.text.TextStyle +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.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), + 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, + 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 +132,118 @@ 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() + + AnimatedElevationScaffold( + modifier = Modifier.testTag("cardWall/intro"), + 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() + ) { + Text( + stringResource(R.string.cdw_missing_capabilities_title), + style = AppTheme.typography.h5 + ) + SpacerSmall() + Text( + stringResource(R.string.cdw_missing_capabilities_info), + style = AppTheme.typography.body1 + ) + SpacerSmall() + 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) + ) } } } @Composable fun AddCardContent( - topColor: Color, - onClickLearnMore: () -> Unit + onClickOrderNow: () -> Unit ) { - Column { - Image( - painterResource(R.drawable.card_wall_man), - null, - alignment = Alignment.BottomStart, - modifier = Modifier - .fillMaxWidth() - .background(topColor) - .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + 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() - 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") - ) - SpacerSmall() - Text( - stringResource(R.string.cdw_intro_body), - style = MaterialTheme.typography.body1 - ) - SpacerSmall() - HintTextLearnMoreButton() + 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() + + val uriHandler = LocalUriHandler.current + val link = stringResource(R.string.cdw_link_health_card_info) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { uriHandler.openUri(link) } + .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), - style = MaterialTheme.typography.subtitle1 + stringResource(R.string.cdw_intro_nfc_card_needed), + style = AppTheme.typography.body1, + modifier = Modifier.weight(1f) ) + 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)) - SpacerMedium() - Text( - stringResource(R.string.cdw_intro_what_you_need_no_egk), - style = MaterialTheme.typography.caption + Icon( + Icons.Outlined.Info, + null, + tint = AppTheme.colors.primary600 ) - 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..f1eeafe1 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/CardWallSecret.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.layout.padding +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.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.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.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.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)) + } + } + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize().padding(it), + contentPadding = PaddingValues(PaddingDefaults.Medium) + ) { + 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(), + secretRange = secretRange, + onSecretChange = onSecretChange, + secret = secret, + label = stringResource(R.string.cdw_pin_label), + next = next + ) + SpacerMedium() + } + } + } +} + +@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 + ), + 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 271329db..503c7b05 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,51 +18,17 @@ 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.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.ClickableTaggedText -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, + profileId: ProfileIdentifier, cardAccessNumber: String, personalIdentificationNumber: String, onFinal: () -> Unit, @@ -70,10 +36,12 @@ fun CardWallTroubleshootingPageA( onBack: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit ) { val dialogState = rememberCardWallAuthenticationDialogState() CardWallAuthenticationDialog( + profileId = profileId, dialogState = dialogState, viewModel = viewModel, authenticationMethod = authenticationMethod, @@ -81,34 +49,24 @@ fun CardWallTroubleshootingPageA( personalIdentificationNumber = personalIdentificationNumber, 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, + profileId: ProfileIdentifier, cardAccessNumber: String, personalIdentificationNumber: String, onFinal: () -> Unit, @@ -116,6 +74,7 @@ fun CardWallTroubleshootingPageB( onBack: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit ) { val dialogState = rememberCardWallAuthenticationDialogState() @@ -123,36 +82,30 @@ fun CardWallTroubleshootingPageB( dialogState = dialogState, viewModel = viewModel, authenticationMethod = authenticationMethod, + profileId = profileId, cardAccessNumber = cardAccessNumber, personalIdentificationNumber = personalIdentificationNumber, 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, + profileId: ProfileIdentifier, cardAccessNumber: String, personalIdentificationNumber: String, onFinal: () -> Unit, @@ -160,10 +113,12 @@ fun CardWallTroubleshootingPageC( onBack: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, + onUnlockEgk: () -> Unit ) { val dialogState = rememberCardWallAuthenticationDialogState() CardWallAuthenticationDialog( + profileId = profileId, dialogState = dialogState, viewModel = viewModel, authenticationMethod = authenticationMethod, @@ -171,207 +126,27 @@ fun CardWallTroubleshootingPageC( personalIdentificationNumber = personalIdentificationNumber, 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() - ClickableTaggedText( - text = text, - style = MaterialTheme.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, - 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..adb1e86b 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,100 +20,91 @@ 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.model.IdpData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier 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.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize -import javax.inject.Inject +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext -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( + private val dispatchers: DispatchProvider +) : ViewModel() { + private data class DelayedState( + val profileId: ProfileIdentifier, val can: String, val pin: String, val authMethod: CardWallData.AuthenticationMethod - ) : Parcelable + ) val defaultState = CardWallData.State( + activeProfileId = "", hardwareRequirementsFulfilled = cardWallUseCase.deviceHasNFCAndAndroidMOrHigher, - isIntroSeenByUser = cardWallUseCase.cardWallIntroIsAccepted, cardAccessNumber = "", selectedAuthenticationMethod = CardWallData.AuthenticationMethod.None, - personalIdentificationNumber = "", - demoMode = demoUseCase.demoModeActive.value, + personalIdentificationNumber = "" ) - private val navState = MutableStateFlow( - savedStateHandle.get(navStateKey) ?: NavState( - can = defaultState.cardAccessNumber, - pin = defaultState.personalIdentificationNumber, - authMethod = defaultState.selectedAuthenticationMethod, + private var delayedState = MutableStateFlow( + DelayedState( + profileId = "", + can = "", + pin = "", + authMethod = CardWallData.AuthenticationMethod.None ) ) - 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(profileId: ProfileIdentifier): Flow { + delayedState.update { + DelayedState( + profileId = profileId, + can = it.can, + pin = it.pin, + authMethod = it.authMethod + ) } - } - fun state(): Flow = - combine( - navState, - demoUseCase.demoModeActive - ) { navState, demo -> + return delayedState.map { navState -> defaultState.copy( + activeProfileId = navState.profileId, cardAccessNumber = navState.can, personalIdentificationNumber = navState.pin, - selectedAuthenticationMethod = navState.authMethod, - demoMode = demo + selectedAuthenticationMethod = navState.authMethod ) + }.onStart { + withContext(dispatchers.IO) { + val authData = cardWallUseCase.authenticationData(profileId).first() + (authData.singleSignOnTokenScope as? IdpData.TokenWithHealthCardScope)?.also { tokenScope -> + delayedState.update { + it.copy( + can = tokenScope.cardAccessNumber, + authMethod = when (authData.singleSignOnTokenScope) { + is IdpData.AlternateAuthenticationToken -> CardWallData.AuthenticationMethod.Alternative + is IdpData.AlternateAuthenticationWithoutToken -> CardWallData.AuthenticationMethod.Alternative + else -> CardWallData.AuthenticationMethod.HealthCard + } + ) + } + } + } } + } fun doAuthentication( + profileId: ProfileIdentifier, can: String, pin: String, method: CardWallData.AuthenticationMethod, @@ -125,43 +116,21 @@ class CardWallViewModel @Inject constructor( method == CardWallData.AuthenticationMethod.None -> error("Authentication method must be set") method == CardWallData.AuthenticationMethod.Alternative && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> authenticationUseCase.pairDeviceWithHealthCardAndSecureElement( + profileId = profileId, can = can, pin = pin, cardChannel = cardChannel ) else -> authenticationUseCase.authenticateWithHealthCard( + profileId = profileId, can = can, pin = pin, 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 6efb8b25..a5431cf3 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,64 +18,298 @@ package de.gematik.ti.erp.app.cardwall.ui -import android.content.Intent -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 com.google.accompanist.insets.systemBarsPadding -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.MainActivity +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.LocalActivity +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 = 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 activity = LocalActivity.current as MainActivity + + 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 + ) + + activity.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 - .systemBarsPadding() .fillMaxWidth() - .padding(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 = 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..80bc4ec0 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/TroubleshootingContent.kt @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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() + + 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..bf28dcc9 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 @@ -19,6 +19,7 @@ package de.gematik.ti.erp.app.cardwall.ui.model import androidx.compose.runtime.Immutable +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier object CardWallData { enum class AuthenticationMethod { @@ -29,13 +30,12 @@ object CardWallData { @Immutable data class State( + val activeProfileId: ProfileIdentifier, + val hardwareRequirementsFulfilled: Boolean, - val isIntroSeenByUser: Boolean, val cardAccessNumber: String, val personalIdentificationNumber: String, - val selectedAuthenticationMethod: AuthenticationMethod, - - val demoMode: Boolean + val selectedAuthenticationMethod: AuthenticationMethod ) } 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/ui/model/CardWallNfcPositionViewModelData.kt similarity index 56% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/command/PsoComputeDigitalSignatureCommand.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardwall/ui/model/CardWallNfcPositionViewModelData.kt index 9403f09e..28bff120 100644 --- 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/ui/model/CardWallNfcPositionViewModelData.kt @@ -16,23 +16,22 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.command +package de.gematik.ti.erp.app.cardwall.ui.model -private const val CLA = 0x00 -private const val INS = 0x2A +import androidx.compose.runtime.Immutable +import de.gematik.ti.erp.app.cardwall.usecase.model.NfcPositionUseCaseData -/** - * 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 +object CardWallNfcPositionViewModelData { + @Immutable + 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..28ae95ee 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,17 +25,21 @@ 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 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 UnlockEgk : Route("UnlockEgk") object NoRoute : Route("") } 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..8c100efd 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,51 @@ 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.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +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 de.gematik.ti.erp.app.secureRandomInstance +import java.io.IOException +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.spec.ECGenParameterSpec +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.onEach +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import org.bouncycastle.util.encoders.Base64 +import org.json.JSONException +import org.json.JSONObject +import io.github.aakira.napier.Napier +import java.security.KeyStoreException @Stable sealed class AuthenticationState { @@ -52,6 +92,9 @@ sealed class AuthenticationState { // IDP failure states object IDPCommunicationFailed : AuthenticationState() + + object UserNotAuthenticated : AuthenticationState() + object IDPCommunicationInvalidCertificate : AuthenticationState() object IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate : AuthenticationState() @@ -75,11 +118,16 @@ sealed class AuthenticationState { HealthCardBlocked, IDPCommunicationFailed, IDPCommunicationInvalidCertificate, + IDPCommunicationInvalidOCSPResponseOfHealthCardCertificate, + UserNotAuthenticated, is InsuranceIdentifierAlreadyExists, SecureElementCryptographyFailed -> true else -> false } + @Stable + fun isNotAuthenticatedFailure() = this == UserNotAuthenticated + @Stable fun isInProgress() = when (this) { @@ -99,21 +147,449 @@ 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) { + InvalidHealthCardCertificate("2020"), + InvalidOCSPResponseOfHealthCardCertificate("2021"), + Unknown("-"); + + companion object { + fun valueOfCode(code: String) = values().find { it.code == code } ?: Unknown + } +} + +private const val KeyStoreAliasKeySize = 32 + +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, + cardChannel: Flow + ): Flow { + val aliasOfSecureElementEntry = ByteArray(KeyStoreAliasKeySize).apply { + secureRandomInstance().nextBytes(this) + } + + return alternatePairingFlowWithSecureElement( + profileId, + can, + pin, + aliasOfSecureElementEntry, + cardChannel + ) + .onEach { Napier.d("AuthenticationState: $it") } + .catch { cause -> + handleException(cause).let { + emit(it) + + 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) + } + } + } + } + + 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 - ): 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) + @Suppress("Deprecation") + private fun alternatePairingFlowWithSecureElement( + profileId: ProfileIdentifier, + 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( + 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(60, KeyProperties.AUTH_BIOMETRIC_STRONG) + } + setIsStrongBoxBacked(true) + setDigests(KeyProperties.DIGEST_SHA256) + + setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + }.build() + + keyPairGenerator.initialize(parameterSpec) + val keyPair = keyPairGenerator.generateKeyPair() + + keyPair.public + } catch (_: 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 { + 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.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) + 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 + ) + } + 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, + 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 ab8f19b6..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/AuthenticationUseCaseProduction.kt +++ /dev/null @@ -1,479 +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 java.io.IOException -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.spec.ECGenParameterSpec -import javax.inject.Inject -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.JSONException -import org.json.JSONObject -import timber.log.Timber - -/** - * 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 { - try { - JSONObject(it.string())["gematik_code"] as? String - } catch (_: JSONException) { - null - } - } - ?.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..5fc715ba 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,41 @@ 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 android.os.Build +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().deviceHasNFCAndAndroidMOrHigher() || cardWallRepository.hasFakeNFCEnabled + set(value) { + cardWallRepository.hasFakeNFCEnabled = value + } + + val deviceHasNFCEnabled + get() = app().nfcEnabled() - suspend fun cardAccessNumberWasSaved(): Flow + suspend fun authenticationData(profileId: ProfileIdentifier): Flow = + idpRepository.authenticationData(profileId) +} - suspend fun getAuthenticationMethod(profileName: String): CardWallData.AuthenticationMethod +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 +} - suspend fun setCardAccessNumber(can: String?) - fun cardAccessNumber(): Flow +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/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/db/daos/ShippingContactDao.kt b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/MiniCardWallUseCase.kt similarity index 60% rename from android/src/main/java/de/gematik/ti/erp/app/db/daos/ShippingContactDao.kt rename to android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/MiniCardWallUseCase.kt index 7ce88f47..cf2021de 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/ShippingContactDao.kt +++ b/android/src/main/java/de/gematik/ti/erp/app/cardwall/usecase/MiniCardWallUseCase.kt @@ -16,21 +16,16 @@ * */ -package de.gematik.ti.erp.app.db.daos +package de.gematik.ti.erp.app.cardwall.usecase -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy.REPLACE -import androidx.room.Query -import de.gematik.ti.erp.app.db.entities.ShippingContactEntity +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 kotlinx.coroutines.flow.Flow -@Dao -interface ShippingContactDao { - - @Query("SELECT * FROM shippingContact") - fun shippingContactFlow(): Flow> - - @Insert(onConflict = REPLACE) - suspend fun insertShippingContact(contact: ShippingContactEntity) +class MiniCardWallUseCase( + private val idpRepository: IdpRepository +) { + fun authenticationData(profileId: ProfileIdentifier): Flow = + idpRepository.authenticationData(profileId) } 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 index 8a6a9d10..c586d639 100644 --- 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 @@ -22,16 +22,13 @@ 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 +class HintUseCase( private val preferences: SharedPreferences ) { private val _cancelledHints = MutableStateFlow(setOf()) 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 index 68bceefe..3abeca48 100644 --- 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 @@ -24,12 +24,4 @@ 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/MainComposable.kt b/android/src/main/java/de/gematik/ti/erp/app/core/MainComposable.kt index f4ff55d1..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) - ProvideWindowInsets(windowInsetsAnimationsEnabled = false) { - AppTheme { - 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) + } - 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..e33e01d0 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,34 @@ 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.idp.usecase.IdpUseCase 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 +import io.github.aakira.napier.Napier +import java.net.URI -@HiltViewModel -class MainViewModel @Inject constructor( +class MainViewModel( private val settingsUseCase: SettingsUseCase, 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 idpUseCase: IdpUseCase +) : ViewModel() { + val zoomEnabled = settingsUseCase.general.map { it.zoomEnabled } + val authenticationMethod = settingsUseCase.authenticationMode + var showOnboarding = runBlocking { settingsUseCase.showOnboarding.first() } private var insecureDevicePromptShown = false val showInsecureDevicePrompt = settingsUseCase .showInsecureDevicePrompt .map { - if (isNewUser) { + if (showOnboarding) { false } else if (!insecureDevicePromptShown) { insecureDevicePromptShown = true @@ -68,28 +69,30 @@ 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) + fun dataProtectionVersionAcceptedOn() = + settingsUseCase.general.map { it.dataProtectionVersionAcceptedOn } + + val hasActiveProfileToken = profilesUseCase.activeProfile + .map { + it.ssoTokenScope != null } - } - fun dataProtectionVersionAccepted() = - settingsUseCase.dataProtectionVersionAccepted() + suspend fun onExternAppAuthorizationResult(uri: URI): Result = + runCatching { + Napier.d("Authenticate external ...") + idpUseCase.authenticateWithExternalAppAuthorization(uri) + Napier.d("... authenticated") + } } 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 index 0465e1f0..f6f1fe3b 100644 --- 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 @@ -21,82 +21,33 @@ package de.gematik.ti.erp.app.db import androidx.room.AutoMigration 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.ShippingContactDao -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.daos.MigrationDao 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.ShippingContactEntity 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 = 27 +const val DB_VERSION = 28 -@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, - ShippingContactEntity::class + MedicationDispenseSimple::class ], version = DB_VERSION, exportSchema = true, autoMigrations = [AutoMigration(from = 26, to = 27)] - -) -@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 - abstract fun shippingContactDao(): ShippingContactDao + abstract fun migrationDao(): MigrationDao } val MIGRATION_1_2 = object : Migration(1, 2) { @@ -185,7 +136,7 @@ val MIGRATION_10_11 = object : Migration(10, 11) { ) 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)", + "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( @@ -288,7 +239,7 @@ val MIGRATION_14_15 = object : Migration(14, 15) { 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") + database.execSQL("UPDATE tasks SET status='' WHERE status IS NOT NULL") } } @@ -331,7 +282,7 @@ val MIGRATION_19_20 = object : Migration(19, 20) { 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)", + "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`" @@ -404,3 +355,79 @@ val MIGRATION_25_26 = object : Migration(25, 26) { database.execSQL("INSERT OR REPLACE INTO `activeProfile` (`id`, `profileName`) VALUES (0, '$DEFAULT_PROFILE_NAME')") } } + +val MIGRATION_27_28 = object : Migration(27, 28) { + 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, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))" + ) + database.execSQL( + "INSERT INTO `tasks_new` (`taskId`, `profileName`, `accessCode`, `lastModified`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `redeemedOn`, `rawKBVBundle`) select `taskId`, `profileName`, `accessCode`, `lastModified`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `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`)") + + // 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 DEFAULT '', `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))" + ) + + 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" + ) + + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_communications_profileName` ON `communications` (`profileName`)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_communications_taskId` ON `communications` (`taskId`)" + ) + + database.execSQL("DROP TABLE auditEvents") + database.execSQL("DROP TABLE idpConfiguration") + database.execSQL("DROP TABLE truststore") + database.execSQL("DROP TABLE shippingContact") + database.execSQL("DROP TABLE activeProfile") + database.execSQL("DROP TABLE lowDetailEvents") + database.execSQL("DROP TABLE safetynetattestations") + database.execSQL("DROP TABLE idpAuthenticationDataEntity") + + database.execSQL("CREATE TABLE IF NOT EXISTS `_new_profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `lastAuthenticated` TEXT, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL)") + database.execSQL("INSERT INTO `_new_profiles` (`lastAuthenticated`, `insurantName`,`color`,`name`,`insuranceName`,`id`,`insuranceIdentifier`) SELECT `lastAuthenticated`, `insurantName`,`color`,`name`,`insuranceName`,`id`,`insuranceIdentifier` FROM `profiles`") + database.execSQL("DROP TABLE `profiles`") + database.execSQL("ALTER TABLE `_new_profiles` RENAME TO `profiles`") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_profiles_name` ON `profiles` (`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/AttestationDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/AttestationDao.kt deleted file mode 100644 index 40029463..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/AttestationDao.kt +++ /dev/null @@ -1,36 +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.SafetynetAttestationEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface AttestationDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAttestation(attestationEntity: SafetynetAttestationEntity) - - @Query("SELECT * FROM safetynetattestations") - fun getAllAttestations(): Flow> -} 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/ActiveSessionDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/MigrationDao.kt similarity index 52% rename from android/src/main/java/de/gematik/ti/erp/app/db/daos/ActiveSessionDao.kt rename to android/src/main/java/de/gematik/ti/erp/app/db/daos/MigrationDao.kt index 22585e28..8642d047 100644 --- 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/MigrationDao.kt @@ -19,23 +19,27 @@ 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 +import de.gematik.ti.erp.app.db.entities.Communication +import de.gematik.ti.erp.app.db.entities.MedicationDispenseSimple +import de.gematik.ti.erp.app.db.entities.ProfileEntity +import de.gematik.ti.erp.app.db.entities.Settings +import de.gematik.ti.erp.app.db.entities.Task @Dao -interface ActiveProfileDao { - @Query("SELECT * FROM activeProfile") - fun activeProfileFlow(): Flow +interface MigrationDao { + @Query("SELECT * FROM settings") + fun getSettings(): Settings? - @Query("SELECT * FROM activeProfile") - suspend fun activeProfile(): ActiveProfile? + @Query("SELECT * FROM profiles") + fun getProfiles(): List - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertActiveProfile(activeProfile: ActiveProfile) + @Query("SELECT * FROM communications") + fun getCommunications(): List - @Query("UPDATE activeProfile SET profileName = :profileName WHERE id = 0") - suspend fun updateActiveProfile(profileName: String) + @Query("SELECT * FROM tasks") + fun getTasks(): List + + @Query("SELECT * FROM medicationDispense") + fun getMedicationDispenses(): List } 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/daos/TruststoreDao.kt b/android/src/main/java/de/gematik/ti/erp/app/db/daos/TruststoreDao.kt deleted file mode 100644 index d8007b5e..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/daos/TruststoreDao.kt +++ /dev/null @@ -1,37 +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.TruststoreEntity - -@Dao -interface TruststoreDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(truststoreEntity: TruststoreEntity) - - @Query("SELECT * FROM truststore") - suspend fun getUntrusted(): TruststoreEntity? - - @Query("DELETE FROM truststore") - suspend fun deleteAll() -} 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 index 3d00815c..c8fceaca 100644 --- 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 @@ -20,31 +20,13 @@ 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" -) +@Deprecated("Remove with Realm migration") +@Entity(tableName = "communications") data class Communication( @PrimaryKey val communicationId: String, @@ -60,6 +42,7 @@ data class Communication( val consumed: Boolean = false ) +@Deprecated("Remove with Realm migration") 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 index 6836fd2c..c135e651 100644 --- 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 @@ -21,8 +21,8 @@ package de.gematik.ti.erp.app.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import java.time.OffsetDateTime +@Deprecated("Remove with Realm migration") @Entity(tableName = "medicationDispense") data class MedicationDispenseSimple( @@ -36,5 +36,5 @@ data class MedicationDispenseSimple( val type: String?, val dosageInstruction: String, val performer: String, // Telematik-ID - val whenHandedOver: OffsetDateTime + val whenHandedOver: String // 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 index 3d746e0b..ab7fd646 100644 --- 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 @@ -21,23 +21,21 @@ 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 +@Deprecated("Remove with Realm migration") @Entity(tableName = "profiles", indices = [Index(value = ["name"], unique = true)]) data class ProfileEntity( @PrimaryKey(autoGenerate = true) val id: Int = 0, val name: String, + val lastAuthenticated: String? = null, // Instant 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 + val color: String = "" // ProfileColorNames ) +@Deprecated("Remove with Realm migration") enum class ProfileColorNames { SPRING_GRAY, SUN_DEW, 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 index 3c8b38d6..9deede5d 100644 --- 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 @@ -21,8 +21,8 @@ package de.gematik.ti.erp.app.db.entities import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey -import java.time.LocalDate +@Deprecated("Remove with Realm migration") enum class SettingsAuthenticationMethod { HealthCard, DeviceSecurity, @@ -39,28 +39,11 @@ enum class SettingsAuthenticationMethod { Unspecified } +@Deprecated("Remove with Realm migration") 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, @@ -71,6 +54,7 @@ data class PharmacySearch( val filterOpenNow: Boolean ) +@Deprecated("Remove with Realm migration") @Entity(tableName = "settings") data class Settings( val authenticationMethod: SettingsAuthenticationMethod, @@ -88,7 +72,7 @@ data class Settings( filterOpenNow = false ), val userHasAcceptedInsecureDevice: Boolean = false, - val dataProtectionVersionAccepted: LocalDate = LocalDate.of(2021, 10, 15) + val dataProtectionVersionAccepted: String // LocalDate.of(2021, 10, 15) ) { @PrimaryKey var id: Long = 0 diff --git a/android/src/main/java/de/gematik/ti/erp/app/db/entities/ShippingContactEntity.kt b/android/src/main/java/de/gematik/ti/erp/app/db/entities/ShippingContactEntity.kt deleted file mode 100644 index 4b3a7b8c..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/db/entities/ShippingContactEntity.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.db.entities - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "shippingContact") -data class ShippingContactEntity( - @PrimaryKey - val id: Int = 0, - val name: String = "", - val line1: String = "", - val line2: String = "", - val postalCodeAndCity: String = "", - val telephoneNumber: String = "", - val mail: String = "", - val deliveryInformation: String = "" -) 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 index f9bf445f..dc31bea6 100644 --- 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 @@ -21,26 +21,10 @@ 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" -) +@Deprecated("Remove with Realm migration") +@Entity(tableName = "tasks") data class Task( @ColumnInfo(name = "taskId") @PrimaryKey @@ -48,74 +32,23 @@ data class Task( @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, + val lastModified: String? = null, // Instant + val expiresOn: String? = null, // LocalDate + val acceptUntil: String? = null, // LocalDate + val authoredOn: String? = null, // OffsetDateTime // synced only val status: TaskStatus? = null, // scan only - val scannedOn: OffsetDateTime? = null, - val scanSessionEnd: OffsetDateTime? = null, + val scannedOn: String? = null, // OffsetDateTime + val scanSessionEnd: String? = null, // OffsetDateTime val nrInScanSession: Int? = null, // serial number of scanned tasks (e.g. 1, 2, ... 5) - val scanSessionName: String? = null, - val redeemedOn: OffsetDateTime? = null, + val redeemedOn: String? = null, // OffsetDateTime @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 - } -} + val rawKBVBundle: ByteArray? = null +) enum class TaskStatus { Ready, InProgress, Completed, 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 cfa6e271..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/demo/usecase/DemoUseCase.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.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 demoTasks = prescriptionDemoDataUseCase.tasks - val demoContact = prescriptionDemoDataUseCase.getDemoContact() - - 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..98927059 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/di/AllModules.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.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.common.usecase.HintUseCase +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 { LazyFhirParser() } + + bindSingleton { EndpointHelper(networkPrefs = instance(NetworkPreferencesTag)) } + + bindSingleton { HintUseCase(instance(ApplicationPreferencesTag)) } + bindSingleton { FeatureToggleManager(instance()) } + + bindSingleton { Analytics(instance()) } + + importAll( + attestationModule, + cardWallModule, + networkModule, + realmModule, + roomModule, + 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 index 394eea21..37b26dfa 100644 --- 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 @@ -40,27 +40,27 @@ class LazyFhirParser : IParser { } override fun getEncodeForceResourceId(): IIdType { - return fhirParser.getEncodeForceResourceId() + return fhirParser.encodeForceResourceId } override fun getEncoding(): EncodingEnum { - return fhirParser.getEncoding() + return fhirParser.encoding } override fun getPreferTypes(): MutableList> { - return fhirParser.getPreferTypes() + return fhirParser.preferTypes } override fun isOmitResourceId(): Boolean { - return fhirParser.isOmitResourceId() + return fhirParser.isOmitResourceId } override fun getStripVersionsFromReferences(): Boolean { - return fhirParser.getStripVersionsFromReferences() + return fhirParser.stripVersionsFromReferences } override fun isSummaryMode(): Boolean { - return fhirParser.isSummaryMode() + return fhirParser.isSummaryMode } override fun parseResource(theResourceType: Class?, theReader: Reader?): T { @@ -100,7 +100,7 @@ class LazyFhirParser : IParser { } override fun isEncodeElementsAppliesToChildResourcesOnly(): Boolean { - return fhirParser.isEncodeElementsAppliesToChildResourcesOnly() + return fhirParser.isEncodeElementsAppliesToChildResourcesOnly } override fun setEncodeForceResourceId(theForceResourceId: IIdType?): IParser { @@ -152,6 +152,6 @@ class LazyFhirParser : IParser { } override fun getDontStripVersionsFromReferencesAtPaths(): MutableSet { - return fhirParser.getDontStripVersionsFromReferencesAtPaths() + return fhirParser.dontStripVersionsFromReferencesAtPaths } } 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..2369bb65 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/di/NetworkModule.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.parser.IParser +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.FhirConverterFactory +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" + +@OptIn(ExperimentalSerializationApi::class) +val networkModule = DI.Module("Network Module") { + bindInstance { + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + } + bindSingleton(JsonConverterFactoryTag) { instance().asConverterFactory("application/json".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(PrefixedLoggerTag) { tagSuffix -> + HttpLoggingInterceptor(NapierLogger(tagSuffix)).also { + if (BuildKonfig.INTERNAL) it.setLevel(HttpLoggingInterceptor.Level.BODY) + } + } + + // 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 fhirParser = instance() + val vauChannelInterceptor = instance() + val userAgentInterceptor = instance() + val apiKeyInterceptor = instance() + val bearerInterceptor = instance() + val endpointHelper = instance() + val innerLoggingInterceptor = instance(PrefixedLoggerTag, "[inner request]") + val outerLoggingInterceptor = instance(PrefixedLoggerTag, "[outer request]") + + 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(FhirConverterFactory.create(fhirParser)) + .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 index ccca8fd5..4107f4be 100644 --- 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 @@ -23,11 +23,7 @@ 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.MessageConversionException 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 @@ -46,6 +42,7 @@ 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_27_28 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 @@ -54,110 +51,104 @@ 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 de.gematik.ti.erp.app.db.entities.CommunicationProfile +import de.gematik.ti.erp.app.db.entities.ProfileColorNames +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.Biometrics +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.DeviceCredentials +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.DeviceSecurity +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.HealthCard +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.None +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.Password +import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.Unspecified +import de.gematik.ti.erp.app.db.entities.TaskStatus +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.entities.v1.SettingsAuthenticationMethodV1 +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +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.MedicationDispenseEntityV1 +import de.gematik.ti.erp.app.db.entities.v1.task.MedicationEntityV1 + +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.toRealmInstant +import de.gematik.ti.erp.app.prescription.model.CommunicationWaitStateDelta +import de.gematik.ti.erp.app.prescription.repository.extractResources +import de.gematik.ti.erp.app.prescription.repository.toInsuranceInformationEntityV1 +import de.gematik.ti.erp.app.prescription.repository.toMedicationEntityV1 +import de.gematik.ti.erp.app.prescription.repository.toMedicationRequestEntityV1 +import de.gematik.ti.erp.app.prescription.repository.toOrganizationEntityV1 +import de.gematik.ti.erp.app.prescription.repository.toPatientEntityV1 +import de.gematik.ti.erp.app.prescription.repository.toPractitionerEntityV1 +import io.realm.kotlin.Realm + +import io.realm.kotlin.ext.toRealmList +import java.time.ZoneOffset +import java.util.UUID 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 - ) +import java.time.LocalDate - 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() - } +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Coverage +import org.hl7.fhir.r4.model.Medication +import org.hl7.fhir.r4.model.MedicationRequest +import org.hl7.fhir.r4.model.Organization +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Practitioner +import io.github.aakira.napier.Napier +import org.kodein.di.DI +import org.kodein.di.bindEagerSingleton +import org.kodein.di.bindSingleton +import org.kodein.di.instance +import java.time.Instant +import java.time.OffsetDateTime - 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") - } +const val REALM_MIGRATION_COMPLETED = "RealmMigrationCompleted" - private fun generatePassPhrase(): String { - return UUID.randomUUID().toString() - } +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" - private fun storePassPhrase( - @RoomDatabaseSecureSharedPreferences sharedPreferences: SharedPreferences, - passPhrase: String - ) { - sharedPreferences.edit().putString( - ENCRYPTED_PREFS_PASSWORD_KEY, - passPhrase - ) - .apply() - } +const val RoomDatabaseSecurePreferencesTag = "RoomDatabaseSecurePreferences" - private fun getPassword(sharedPreferences: SharedPreferences): String? { - return sharedPreferences.getString( - ENCRYPTED_PREFS_PASSWORD_KEY, - null - ) - } +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, + MIGRATION_27_28 +) - @RoomDatabaseSecureSharedPreferences - @Singleton - @Provides - fun providesSecureSharedPreferences(@ApplicationContext context: Context): SharedPreferences { - return EncryptedSharedPreferences.create( +val roomModule = DI.Module("roomModule") { + bindSingleton(RoomDatabaseSecurePreferencesTag) { + val context = instance() + + EncryptedSharedPreferences.create( context, ENCRYPTED_PREFS_FILE_NAME, MasterKey.Builder(context, MASTER_KEY_ALIAS) @@ -166,4 +157,245 @@ object RoomModule { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } + bindEagerSingleton { + val context = instance() + val realm = instance() + val appPrefs = instance(ApplicationPreferencesTag) + val securePrefs = instance(RoomDatabaseSecurePreferencesTag) + try { + val passphrase: ByteArray = + SQLiteDatabase.getBytes(getPassphrase(securePrefs).toCharArray()) + val factory = SupportFactory(passphrase) + Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "db" + ) + .addMigrations(*migrations) + .openHelperFactory(factory) + .allowMainThreadQueries() + .build().also { db -> + if (!appPrefs.getBoolean(REALM_MIGRATION_COMPLETED, false)) { + migrateRoomToRealm(db, realm, appPrefs) + appPrefs.edit().putBoolean(REALM_MIGRATION_COMPLETED, true).commit() + } + } + } catch (expected: Throwable) { + throw MessageConversionException(expected) + } + } +} + +private fun migrateRoomToRealm(db: AppDatabase, realm: Realm, appPrefs: SharedPreferences) { + db.migrationDao().getSettings()?.also { settingsSql -> + realm.writeBlocking { + findLatest(queryFirst()!!)?.apply { + this.authenticationMethod = when (settingsSql.authenticationMethod) { + HealthCard -> SettingsAuthenticationMethodV1.HealthCard + DeviceSecurity -> SettingsAuthenticationMethodV1.DeviceSecurity + Biometrics -> SettingsAuthenticationMethodV1.Biometrics + DeviceCredentials -> SettingsAuthenticationMethodV1.DeviceCredentials + Password -> SettingsAuthenticationMethodV1.Password + None -> SettingsAuthenticationMethodV1.None + Unspecified -> SettingsAuthenticationMethodV1.Unspecified + } + this.authenticationFails = settingsSql.authenticationFails + this.zoomEnabled = settingsSql.zoomEnabled + this.pharmacySearch?.name = settingsSql.pharmacySearch.name + this.pharmacySearch?.locationEnabled = settingsSql.pharmacySearch.locationEnabled + this.pharmacySearch?.filterDeliveryService = + settingsSql.pharmacySearch.filterDeliveryService + this.pharmacySearch?.filterOnlineService = settingsSql.pharmacySearch.filterOnlineService + this.pharmacySearch?.filterReady = settingsSql.pharmacySearch.filterReady + this.pharmacySearch?.filterOpenNow = settingsSql.pharmacySearch.filterOpenNow + this.userHasAcceptedInsecureDevice = settingsSql.userHasAcceptedInsecureDevice + this.dataProtectionVersionAccepted = + LocalDate.parse(settingsSql.dataProtectionVersionAccepted).atStartOfDay() + .toInstant(ZoneOffset.UTC) + .toRealmInstant() + this.password?.hash = settingsSql.password?.hash ?: byteArrayOf() + this.password?.salt = settingsSql.password?.salt ?: byteArrayOf() + + if (!appPrefs.getBoolean("newUser", true)) { + this.onboardingLatestAppVersionName = this.latestAppVersionName + this.onboardingLatestAppVersionCode = this.latestAppVersionCode + } + } + } + } + + val profiles = db.migrationDao().getProfiles().map { profile -> + ProfileEntityV1().apply { + this.name = profile.name + this.insurantName = profile.insurantName + this.insuranceIdentifier = profile.insuranceIdentifier + this.insuranceName = profile.insuranceName + this.lastAuthenticated = profile.lastAuthenticated?.let { + Instant.parse(profile.lastAuthenticated).toRealmInstant() + } + this.color = when (profile.color) { + ProfileColorNames.SPRING_GRAY.name -> ProfileColorNamesV1.SPRING_GRAY + ProfileColorNames.SUN_DEW.name -> ProfileColorNamesV1.SUN_DEW + ProfileColorNames.PINK.name -> ProfileColorNamesV1.PINK + ProfileColorNames.TREE.name -> ProfileColorNamesV1.TREE + ProfileColorNames.BLUE_MOON.name -> ProfileColorNamesV1.BLUE_MOON + else -> ProfileColorNamesV1.SPRING_GRAY + } + } + } + + val communications = db.migrationDao().getCommunications().map { com -> + CommunicationEntityV1().apply { + this.communicationId = com.communicationId + this.profile = when (com.profile) { + CommunicationProfile.ErxCommunicationDispReq -> CommunicationProfileV1.ErxCommunicationDispReq + CommunicationProfile.ErxCommunicationReply -> CommunicationProfileV1.ErxCommunicationReply + } + this.taskId = com.taskId + this.sentOn = Instant.now().minus(CommunicationWaitStateDelta).toRealmInstant() + this.sender = when (com.profile) { + CommunicationProfile.ErxCommunicationDispReq -> com.kbvUserId + CommunicationProfile.ErxCommunicationReply -> com.telematicsId + } + this.recipient = when (com.profile) { + CommunicationProfile.ErxCommunicationDispReq -> com.telematicsId + CommunicationProfile.ErxCommunicationReply -> com.kbvUserId + } + this.payload = com.payload + this.consumed = com.consumed + } + } + + val medicationDispenses = db.migrationDao().getMedicationDispenses().map { medicationDispense -> + MedicationDispenseEntityV1().apply { + this.dispenseId = medicationDispense.taskId + this.patientIdentifier = medicationDispense.patientIdentifier + this.medication = MedicationEntityV1().apply { + this.text = medicationDispense.text ?: "" + this.form = medicationDispense.type + this.normSizeCode = null // was not extracted + this.uniqueIdentifier = medicationDispense.uniqueIdentifier + } + this.wasSubstituted = medicationDispense.wasSubstituted + this.dosageInstruction = medicationDispense.dosageInstruction + this.performer = medicationDispense.performer + this.whenHandedOver = medicationDispense.whenHandedOver.let { Instant.parse(it) }?.toRealmInstant()!! + } + } + + db.migrationDao().getTasks().map { task -> + if (task.rawKBVBundle == null) { + val scannedTask = ScannedTaskEntityV1().apply { + this.taskId = task.taskId + this.accessCode = task.accessCode!! + this.redeemedOn = task.redeemedOn?.let { OffsetDateTime.parse(it) }?.toInstant()?.toRealmInstant() + this.scannedOn = task.scannedOn?.let { OffsetDateTime.parse(it) }?.toInstant()?.toRealmInstant()!! + } + profiles.find { + it.name == task.profileName + }?.scannedTasks?.add(scannedTask) + } + } + + db.migrationDao().getTasks().map { task -> + if (task.rawKBVBundle != null) { + try { + val fhirParser = LazyFhirParser() + val syncedTask = SyncedTaskEntityV1().apply { + val bundle = fhirParser.parseResource(task.rawKBVBundle.decodeToString()) as Bundle + val medicationRequest = bundle.extractResources().first() + val medication = bundle.extractResources().first() + val organization = bundle.extractResources().first() + val practitioner = bundle.extractResources().first() + val patient = bundle.extractResources().first() + val insuranceInformation = bundle.extractResources().first() + + this.taskId = task.taskId + this.accessCode = task.accessCode + this.lastModified = Instant.parse(task.lastModified).toRealmInstant() + this.status = when (task.status) { + TaskStatus.Ready -> TaskStatusV1.Ready + TaskStatus.InProgress -> TaskStatusV1.InProgress + TaskStatus.Completed -> TaskStatusV1.Completed + else -> TaskStatusV1.Other + } + this.expiresOn = + LocalDate.parse(task.expiresOn!!).atStartOfDay().toInstant(ZoneOffset.UTC).toRealmInstant() + this.acceptUntil = LocalDate.parse(task.acceptUntil!!).atStartOfDay().toInstant(ZoneOffset.UTC) + .toRealmInstant() + this.authoredOn = OffsetDateTime.parse(task.authoredOn!!).toInstant().toRealmInstant() + this.organization = organization.toOrganizationEntityV1() + this.practitioner = practitioner.toPractitionerEntityV1() + this.patient = patient.toPatientEntityV1() + this.insuranceInformation = insuranceInformation.toInsuranceInformationEntityV1() + this.status = when (task.status) { + TaskStatus.Ready -> TaskStatusV1.Ready + TaskStatus.InProgress -> TaskStatusV1.InProgress + TaskStatus.Completed -> TaskStatusV1.Completed + else -> TaskStatusV1.Other + } + this.medicationRequest = + medicationRequest.toMedicationRequestEntityV1(medication.toMedicationEntityV1()) + this.medicationDispenses += medicationDispenses.filter { + it.dispenseId == task.taskId + }.toRealmList() + val communicationsFromTask = communications.filter { + it.taskId == task.taskId + } + this.communications += communicationsFromTask + } + profiles.find { + it.name == task.profileName + }?.apply { + this.syncedTasks += syncedTask + } + } catch (expected: Exception) { + Napier.e("Migration error", expected) + } + } + } + + val profileIsUnused = profiles.size == 1 && profiles.first().name == "" && appPrefs.getBoolean("newUser", true) + + if (!profileIsUnused) { + realm.writeBlocking { + profiles.forEach { + copyToRealm(it) + } + queryFirst()?.let { profileToActivate -> + profileToActivate.active = true + } + } + } +} + +private fun generatePassPhrase(): String { + return UUID.randomUUID().toString() +} + +private fun storePassPhrase( + sharedPreferences: SharedPreferences, + passPhrase: String +) { + sharedPreferences.edit().putString( + ENCRYPTED_PREFS_PASSWORD_KEY, + passPhrase + ) + .apply() +} + +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 getPassword(sharedPreferences: SharedPreferences): String? { + return sharedPreferences.getString( + ENCRYPTED_PREFS_PASSWORD_KEY, + null + ) } 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 acd82705..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,19 +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"), - BIO_LOGIN("BioLogin") + 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 bbf717a5..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/repository/IdpRepository.kt +++ /dev/null @@ -1,387 +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.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.PairingResponseEntries -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.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 { - 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 = REDIRECT_URI - ) = - 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) = - 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 getPairing( - url: String, - token: String - ): Result = - remoteDataSource.getPairing( - url, - token = token, - ) - - 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 { - return remoteDataSource.requestAuthorizationRedirect( - url = url, externalAppId = kkAppId, - codeChallenge = codeChallenge, - nonce = nonce.nonce, - state = state.state - ).getOrThrow() - } -} 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 9c563402..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpUseCase.kt +++ /dev/null @@ -1,465 +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.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 java.io.IOException -import java.net.URI -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 kotlinx.coroutines.sync.withLock -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 IDPConfigException(cause: Throwable) : IOException(cause) - -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 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, 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 = try { - basicUseCase.initializeConfigurationAndKeys() - } catch (e: Exception) { - throw IDPConfigException(e) - } - 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 - ) - 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(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 = ( - cryptoProvider.keyStoreInstance() - .apply { load(null) } - .getEntry( - aliasOfSecureElementEntry.decodeToString(), - null - ) as KeyStore.PrivateKeyEntry - ).privateKey - signatureObjectOfSecureElementEntry = cryptoProvider.signatureInstance() - } catch (e: Exception) { - // the system might have removed the key during biometric reenrollment - // therefore there's 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 getPairedDevicesWithSecureElement(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 = ( - cryptoProvider.keyStoreInstance() - .apply { load(null) } - .getEntry( - aliasOfSecureElementEntry.decodeToString(), - null - ) as KeyStore.PrivateKeyEntry - ).privateKey - signatureObjectOfSecureElementEntry = - cryptoProvider.signatureInstance() - } catch (e: Exception) { - // the system might have removed the key during biometric reenrollment - // therefore there's no choice but to delete everything - repository.invalidate(profileName) - throw AltAuthenticationCryptoException(e) - } - - val initialData = basicUseCase.initializeConfigurationAndKeys() - val challengeData = basicUseCase.challengeFlow(initialData, scope = IdpScope.BiometricPairing) - - val authData = altAuthUseCase.authenticateWithSecureElement( - initialData = initialData, - challenge = challengeData.challenge, - healthCardCertificate = healthCardCertificate, - authenticationMethod = IdpAlternateAuthenticationUseCase.AuthenticationMethod.Strong, - aliasOfSecureElementEntry = aliasOfSecureElementEntry, - privateKeyOfSecureElementEntry = privateKeyOfSecureElementEntry, - signatureObjectOfSecureElementEntry = signatureObjectOfSecureElementEntry, - ) - - altAuthUseCase.getPairedDevices( - initialData = initialData, - accessToken = authData.accessToken - ) - } - - suspend fun getPairedDevices( - healthCardCertificate: suspend () -> ByteArray, - sign: 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 = sign - ) - - altAuthUseCase.getPairedDevices( - initialData = initialData, - accessToken = basicData.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 2ed2bfa4..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,38 +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 private const val invalidAccessTokenHeader = "Www-Authenticate" private const val invalidAccessTokenValue = "Bearer realm='prescriptionserver.telematik', error='invalACCESS_TOKEN'" -class BearerHeadersInterceptor( - private val idpUseCase: IdpUseCase, +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))) + 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 ) { - Timber.d("Received 401 -> refresh access token") - chain.proceed(request(original, loadAccessToken(true, profileName))) + 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) = @@ -64,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() @@ -73,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..abed45ad --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/FastTrackComponents.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.mainscreen.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.MainActivity +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.core.LocalActivity +import de.gematik.ti.erp.app.core.MainViewModel +import de.gematik.ti.erp.app.utils.compose.AcceptDialog + +@Composable +fun ExternalAuthenticationDialog( + mainViewModel: MainViewModel +) { + var showAuthenticationError by remember { mutableStateOf(false) } + + val activity = LocalActivity.current + LaunchedEffect(Unit) { + // This ensures that we only trigger the authorization if we are returning from the main card wall + mainViewModel.hasActiveProfileToken.collect { + if (!it) { + (activity as MainActivity).unvalidatedInstantUri.collect { uri -> + mainViewModel.onExternAppAuthorizationResult(uri) + .onFailure { 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/MainScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/MainScreenComponents.kt index f9f15b51..65eb9737 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 @@ -20,12 +20,10 @@ package de.gematik.ti.erp.app.mainscreen.ui 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.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -36,7 +34,6 @@ 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight @@ -45,10 +42,10 @@ 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.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 @@ -63,37 +60,35 @@ 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.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State 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.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight 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 @@ -101,73 +96,78 @@ 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 androidx.compose.foundation.layout.systemBarsPadding import com.google.mlkit.common.sdkinternal.MlKitContext import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.TestTag +import de.gematik.ti.erp.app.TestTag.Main.Profile.OpenProfileListButton 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.idp.model.IdpData 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.pharmacy.ui.PharmacyNavigation import de.gematik.ti.erp.app.prescription.detail.ui.PrescriptionDetailsScreen -import de.gematik.ti.erp.app.prescription.ui.EmptyScreenState import de.gematik.ti.erp.app.prescription.ui.HomeNoHealthCardSignInHint import de.gematik.ti.erp.app.prescription.ui.PrescriptionScreen +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.prescription.ui.model.PrescriptionScreenData import de.gematik.ti.erp.app.profiles.ui.Avatar import de.gematik.ti.erp.app.profiles.ui.EditProfileScreen +import de.gematik.ti.erp.app.profiles.ui.LocalProfileHandler +import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel 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.redeem.ui.RedeemScreen +import de.gematik.ti.erp.app.settings.ui.AllowBiometryScreen +import de.gematik.ti.erp.app.settings.model.SettingsData 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.analytics.TrackNavigationChanges +import de.gematik.ti.erp.app.orders.ui.MessageScreen +import de.gematik.ti.erp.app.orders.ui.OrderScreen +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier 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.Dialog import de.gematik.ti.erp.app.utils.compose.NavigationAnimation +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.SpacerTiny import de.gematik.ti.erp.app.utils.compose.TopAppBarWithContent -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 de.gematik.ti.erp.app.utils.compose.visualTestTag import de.gematik.ti.erp.app.utils.dateTimeShortText import de.gematik.ti.erp.app.webview.URI_DATA_TERMS import de.gematik.ti.erp.app.webview.WebViewScreen -import java.time.LocalDate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.kodein.di.compose.rememberViewModel +import java.time.Instant -@OptIn(ExperimentalMaterialApi::class) +@Suppress("LongMethod") @Composable fun MainScreen( navController: NavHostController, mainViewModel: MainViewModel, - settingsViewModel: SettingsViewModel = hiltViewModel() + 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()) { @@ -180,7 +180,7 @@ fun MainScreen( val startDestination = when { - mainViewModel.isNewUser -> { + mainViewModel.showOnboarding -> { MainNavigationScreens.Onboarding.route } else -> { @@ -190,27 +190,47 @@ fun MainScreen( TrackNavigationChanges(navController) val navigationMode by navController.navigationModeState(OnboardingNavigationScreens.Onboarding.route) - + var selectedPrescriptionScreenTab by remember { mutableStateOf(PrescriptionTabs.Redeemable) } + 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) { @@ -227,41 +247,44 @@ fun MainScreen( MainNavigationScreens.Settings.arguments ) { val scrollTo = remember { it.arguments?.get("scrollToSection") as SettingsScrollTo } - SettingsScreen(scrollTo = scrollTo, navController) + SettingsScreen( + scrollTo = scrollTo, + mainNavController = navController, + profileSettingsViewModel = profileSettingsViewModel, + settingsViewModel = settingsViewModel + ) } composable(MainNavigationScreens.Camera.route) { - ScanScreen(navController) + val scanViewModel by rememberViewModel() + ScanScreen(mainNavController = navController, scanViewModel = scanViewModel) } composable(MainNavigationScreens.Prescriptions.route) { - MainScreenWithScaffold(navController, mainViewModel) - } - 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() - } + val mainScreenVM by rememberViewModel() + MainScreenWithScaffold( + mainNavController = navController, + selectedTab = selectedPrescriptionScreenTab, + onSelectedTab = { selectedPrescriptionScreenTab = it }, + mainViewModel = mainViewModel, + mainScreenViewModel = mainScreenVM + ) } + 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) + val mainScreenVM by rememberViewModel() + PharmacyNavigation( + mainNavController = navController, + mainScreenVM = mainScreenVM + ) } composable(MainNavigationScreens.InsecureDeviceScreen.route) { InsecureDeviceScreen( @@ -293,51 +316,53 @@ 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 ) @@ -345,49 +370,46 @@ fun MainScreen( } } -@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() + selectedTab: PrescriptionTabs, + onSelectedTab: (PrescriptionTabs) -> Unit, + mainViewModel: MainViewModel, + mainScreenViewModel: MainScreenViewModel ) { - LaunchedEffect(Unit) { - if (mainViewModel.showDataTermsUpdate.first()) { - mainNavController.navigate( - MainNavigationScreens.DataTermsUpdateScreen.path(), - navOptions { - launchSingleTop = true - popUpTo(MainNavigationScreens.Prescriptions.path()) { - inclusive = true + 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()) - } else if (mainViewModel.showProfileSetupPrompt.first()) { - mainNavController.navigate(MainNavigationScreens.ProfileSetup.path()) + ) + } else if (mainViewModel.showInsecureDevicePrompt.first()) { + mainNavController.navigate(MainNavigationScreens.InsecureDeviceScreen.path()) + } } } LaunchedEffect(Unit) { mainViewModel.showSafetynetPrompt.collect { if (!it) { - mainNavController.navigate(MainNavigationScreens.SafetynetNotOkScreen.route) + withContext(Dispatchers.Main) { + mainNavController.navigate(MainNavigationScreens.SafetynetNotOkScreen.route) + } } } } val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - val redeemState by produceState(MainScreenData.emptyRedeemState) { - mainScreenVM.redeemState().collect { - value = it - } - } - LaunchedEffect(Unit) { sheetState.snapTo(ModalBottomSheetValue.Hidden) } @@ -395,117 +417,119 @@ fun MainScreenWithScaffold( val scaffoldState = rememberScaffoldState() MainScreenSnackbar( - mainScreenViewModel = mainScreenVM, - scaffoldState = scaffoldState, + mainScreenViewModel = mainScreenViewModel, + scaffoldState = scaffoldState ) - OrderSuccessDialog(mainScreenVM) + OrderSuccessDialog(mainScreenViewModel) val coroutineScope = rememberCoroutineScope() + val profileHandler = LocalProfileHandler.current + val redeemState = rememberRedeemState(profileHandler.activeProfile) ModalBottomSheetLayout( sheetState = sheetState, 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) + RedeemBottomSheetContent( + redeemState = redeemState, + onClickLocalRedeem = { + mainNavController.navigate( + MainNavigationScreens.RedeemLocally.path( + TaskIds(it) + ) ) - ) - } - - 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) + }, + onClickOnlineRedeem = { + mainNavController.navigate( + MainNavigationScreens.Pharmacies.path( + TaskIds(it) + ) ) - ) - } - - Box(Modifier.navigationBarsHeight()) + } + ) } ) { val bottomNavController = rememberNavController() - var selectedPrescriptionScreenTab by remember { mutableStateOf(PrescriptionTabs.Redeemable) } + val currentBottomNavigationRoute by bottomNavController + .currentBackStackEntryFlow + .collectAsState(null) - val showFab by produceState(false, key1 = selectedPrescriptionScreenTab) { - bottomNavController.currentBackStackEntryFlow.collect { - value = selectedPrescriptionScreenTab == PrescriptionTabs.Redeemable && - it.destination.route == MainNavigationScreens.Prescriptions.route - } - } + var emptyScreenState by remember { mutableStateOf(PrescriptionScreenData.EmptyActiveScreenState.NotEmpty) } - val showRedeemAndArchiveTopBar by produceState(true) { + val showLogInHint by produceState(false, key1 = emptyScreenState, key2 = selectedTab) { bottomNavController.currentBackStackEntryFlow.collect { - value = it.destination.route == MainNavigationScreens.Prescriptions.route - } - } - - var emptyScreenState by remember { mutableStateOf(EmptyScreenState.NotEmpty) } - - val showLogInHint by produceState(false, key1 = emptyScreenState, key2 = selectedPrescriptionScreenTab) { - bottomNavController.currentBackStackEntryFlow.collect { - value = emptyScreenState == EmptyScreenState.NoHealthCard && - selectedPrescriptionScreenTab == PrescriptionTabs.Redeemable && + value = emptyScreenState == PrescriptionScreenData.EmptyActiveScreenState.NeverConnected && + selectedTab == PrescriptionTabs.Redeemable && it.destination.route == MainNavigationScreens.Prescriptions.route } } + ExternalAuthenticationDialog(mainViewModel) + val listState = rememberLazyListState() Scaffold( + modifier = Modifier.testTag(TestTag.Main.MainScreen), topBar = { + val isInPrescriptionSreen by derivedStateOf { + currentBottomNavigationRoute?.destination?.route == MainNavigationScreens.Prescriptions.route + } + MultiProfileTopAppBar( navController = mainNavController, - mainScreenVieModel = mainScreenVM, - tabBar = if (showRedeemAndArchiveTopBar) { - { - RedeemAndArchiveTabs( - selectedTab = selectedPrescriptionScreenTab, - onSelectedTab = { selectedPrescriptionScreenTab = it } - ) - } - } else null + title = when (currentBottomNavigationRoute?.destination?.route) { + MainNavigationScreens.Prescriptions.route -> + stringResource(R.string.pres_bottombar_prescriptions) + MainNavigationScreens.Orders.route -> + stringResource(R.string.pres_bottombar_orders) + else -> "" + }, + elevated = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0, + tabBar = { + RedeemAndArchiveTabs( + selectedTab = selectedTab, + onSelectedTab = onSelectedTab + ) + }, + scanButtonVisible = isInPrescriptionSreen, + tabBarVisible = isInPrescriptionSreen ) }, bottomBar = { MainScreenBottomNavigation( navController = mainNavController, + viewModel = mainScreenViewModel, bottomNavController = bottomNavController, - signInHint = { - if (showLogInHint) { + profileId = profileHandler.activeProfile.id, + signInHint = if (showLogInHint) { + { HomeNoHealthCardSignInHint( - onClickAction = { mainNavController.navigate(MainNavigationScreens.CardWall.route) } + onClickAction = { + coroutineScope.launch { + mainNavController.navigate( + MainNavigationScreens.CardWall.path(profileHandler.activeProfile.id) + ) + } + } ) - } else { - null } + } else { + null } ) }, 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() } } - ) + val showRedeemFab by derivedStateOf { + redeemState.hasRedeemableTasks && selectedTab == PrescriptionTabs.Redeemable && + currentBottomNavigationRoute?.destination?.route == MainNavigationScreens.Prescriptions.route } + RedeemFloatingActionButton( + visible = showRedeemFab, + onClick = { + coroutineScope.launch { + sheetState.show() + } + } + ) }, scaffoldState = scaffoldState ) { innerPadding -> @@ -519,15 +543,17 @@ fun MainScreenWithScaffold( startDestination = MainNavigationScreens.Prescriptions.path() ) { composable(MainNavigationScreens.Prescriptions.route) { + val prescriptionViewModel by rememberViewModel() PrescriptionScreen( navController = mainNavController, - uri = mainViewModel.externalAuthorizationUri, - selectedTab = selectedPrescriptionScreenTab, - displayedScreen = { emptyScreenState = it } + selectedTab = selectedTab, + listState = listState, + onEmptyScreenChange = { emptyScreenState = it }, + prescriptionViewModel = prescriptionViewModel ) } - composable(MainNavigationScreens.Messages.route) { - MessageScreen(mainNavController, messageVM) + composable(MainNavigationScreens.Orders.route) { + OrderScreen(mainNavController = mainNavController) } } } @@ -536,16 +562,17 @@ fun MainScreenWithScaffold( } @Composable -private fun MainScreenBottomNavigation( +fun MainScreenBottomNavigation( navController: NavController, bottomNavController: NavController, - viewModel: MainScreenViewModel = hiltViewModel(LocalActivity.current), + viewModel: MainScreenViewModel, + profileId: ProfileIdentifier, signInHint: (@Composable () -> Unit)? = null ) { 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( @@ -554,7 +581,7 @@ private fun MainScreenBottomNavigation( AnimatedVisibility( visible = signInHint != null, enter = fadeIn(), - exit = fadeOut(), + exit = fadeOut() ) { signInHint?.invoke() } } ) { @@ -562,10 +589,10 @@ private fun MainScreenBottomNavigation( 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 -> "" } ), @@ -578,15 +605,17 @@ 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 ) } }, @@ -595,7 +624,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 @@ -609,12 +638,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.Settings.route, + MainNavigationScreens.Pharmacies.route -> + navController.navigate(screen.path()) + else -> + bottomNavController.navigate(screen.path()) } } } @@ -623,51 +652,15 @@ private fun MainScreenBottomNavigation( } } -@Preview(showBackground = true) @Composable -fun TopAppBarMultiUserPreview() { - AppTheme { - TopAppBarMultiUser(mainScreenViewModel = hiltViewModel(LocalActivity.current), {}, {}) - } -} - -@Composable -fun TopAppBarMultiUser( - mainScreenViewModel: MainScreenViewModel, - onClickEdit: (Int) -> Unit, +fun TopAppBarMultiUserTitle( + onClickEdit: (String) -> Unit, + title: String, onClickEditProfiles: () -> Unit ) { - - 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 - }!! - - val lastAuthenticatedDate = remember(activeProfile) { - activeProfile.lastAuthenticated?.let { - dateTimeShortText(it) - } - } - - 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) + val profileHandler = LocalProfileHandler.current + val ssoTokenScope = profileHandler.activeProfile.ssoTokenScope + val ssoStatusColor = ssoStatusColor(profileHandler.activeProfile, ssoTokenScope) var expanded by remember { mutableStateOf(false) } @@ -679,68 +672,51 @@ fun TopAppBarMultiUser( .clip(CircleShape) .clickable { expanded = !expanded } .padding(PaddingDefaults.Tiny) + .visualTestTag(OpenProfileListButton) ) { - Avatar(Modifier.size(36.dp), 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) - ) - } - - Text( - text = ssoText, - color = ssoTextColor, - style = AppTheme.typography.captionl + Avatar(Modifier.size(36.dp), profile = profileHandler.activeProfile, ssoStatusColor) + SpacerShortMedium() + SpacerSmall() + Text( + text = title, + style = AppTheme.typography.h6, + fontWeight = FontWeight.W500, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + SpacerMedium() + if (expanded) { + val scope = rememberCoroutineScope() + + ProfileSelector( + onClickEdit = onClickEdit, + onClickEditProfiles = onClickEditProfiles, + onClickProfile = { + scope.launch { profileHandler.switchActiveProfile(it) } + }, + userList = profileHandler.profiles, + onDismiss = { expanded = false } ) - - if (expanded) { - ProfileSelector( - onClickEdit = onClickEdit, - onClickEditProfiles = onClickEditProfiles, - onClickProfile = { mainScreenViewModel.saveActiveProfile(it) }, - userList = profileList, - onDismiss = { expanded = false }, - ) - } } } } @Composable -fun ssoStatusColor(profile: ProfilesUseCaseData.Profile, ssoToken: SingleSignOnToken?) = +fun ssoStatusColor(profile: ProfilesUseCaseData.Profile, ssoTokenScope: IdpData.SingleSignOnTokenScope?) = when { - ssoToken?.isValid() == true -> AppTheme.colors.green400 + ssoTokenScope?.token?.isValid() == true -> AppTheme.colors.green400 profile.lastAuthenticated != null -> AppTheme.colors.red400 else -> null } @Composable -private fun ProfileSelector( - onClickEdit: (Int) -> Unit, +fun ProfileSelector( + onClickEdit: (String) -> Unit, onClickEditProfiles: () -> Unit, onClickProfile: (ProfilesUseCaseData.Profile) -> Unit, - userList: List, + userList: State>, onDismiss: () -> Unit ) { - val dismissModifier = Modifier.clickable( onClick = onDismiss, @@ -749,7 +725,7 @@ private fun ProfileSelector( ) Dialog( - onDismissRequest = onDismiss, + onDismissRequest = onDismiss ) { Box( Modifier @@ -769,7 +745,6 @@ private fun ProfileSelector( shape = RoundedCornerShape(28.dp), elevation = 8.dp ) { - AnimatedVisibility( visibleState = remember { MutableTransitionState(false) }.apply { targetState = true @@ -778,12 +753,12 @@ private fun ProfileSelector( exit = ExitTransition.None ) { - Box() { + Box { Column(modifier = Modifier.padding(bottom = 56.dp)) { Row(modifier = Modifier.fillMaxWidth()) { Text( text = stringResource(R.string.select_profile), - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2, color = AppTheme.colors.neutral600, modifier = Modifier .padding( @@ -811,7 +786,7 @@ private fun ProfileSelector( modifier = Modifier.testTag("profileList"), state = listState ) { - userList.forEach { + userList.value.forEach { item { ProfileCard( profile = it, @@ -845,12 +820,11 @@ private fun ProfileSelector( @Composable fun ProfileCard( profile: ProfilesUseCaseData.Profile, - onClickEdit: (Int) -> Unit, + onClickEdit: (String) -> Unit, onClickProfile: (profile: ProfilesUseCaseData.Profile) -> Unit, onDismiss: () -> Unit ) { - val colors = profileColor(profileColorNames = profile.color) - val profileSsoToken = profile.ssoToken + val profileSsoTokenScope = profile.ssoTokenScope Row( modifier = Modifier @@ -868,13 +842,14 @@ fun ProfileCard( .weight(1f) .padding(PaddingDefaults.Medium) ) { - Avatar(Modifier.size(36.dp), profile.name, colors, null, active = profile.active) + Avatar(Modifier.size(36.dp), profile, null, active = profile.active) SpacerSmall() Column { Text( - profile.name, style = MaterialTheme.typography.body1, + profile.name, + style = AppTheme.typography.body1 ) val lastAuthenticatedDateText = @@ -885,17 +860,19 @@ fun ProfileCard( ) } } - val connectedText = connectionText(profileSsoToken, lastAuthenticatedDateText) - val connectedColor = connectionTextColor(profileSsoToken) + val connectedText = connectionText(profileSsoTokenScope?.token, lastAuthenticatedDateText) + val connectedColor = connectionTextColor(profileSsoTokenScope?.token) Text( - connectedText, style = AppTheme.typography.captionl, - color = connectedColor, + connectedText, + style = AppTheme.typography.caption1l, + color = connectedColor ) } } TextButton( + modifier = Modifier.visualTestTag(TestTag.Main.Profile.ProfileDetailsButton), onClick = { onClickEdit(profile.id) } @@ -913,17 +890,18 @@ fun ProfileCard( @Composable fun MultiProfileTopAppBar( navController: NavController, - mainScreenVieModel: MainScreenViewModel, - tabBar: (@Composable () -> Unit)? = null + tabBar: @Composable () -> Unit, + title: String, + elevated: Boolean, + scanButtonVisible: Boolean = false, + tabBarVisible: Boolean = false ) { val accScan = stringResource(R.string.main_scan_acc) - val context = LocalContext.current - val demoToastText = stringResource(R.string.function_not_availlable_on_demo_mode) - + val elevation = remember(elevated) { if (elevated) AppBarDefaults.TopAppBarElevation else 0.dp } TopAppBarWithContent( title = { - TopAppBarMultiUser( - mainScreenVieModel, + TopAppBarMultiUserTitle( + title = title, onClickEditProfiles = { navController.navigate( MainNavigationScreens.Settings.path( @@ -932,63 +910,59 @@ fun MultiProfileTopAppBar( ) }, onClickEdit = { - if (mainScreenVieModel.isDemoActive()) { - createToastShort(context, demoToastText) - } else { - navController.navigate(MainNavigationScreens.EditProfile.path(it)) - } + navController.navigate(MainNavigationScreens.EditProfile.path(it)) } ) }, - elevation = 8.dp, + elevation = elevation, backgroundColor = MaterialTheme.colors.surface, actions = @Composable { - var showMlKitPermissionDialog by remember { mutableStateOf(false) } + if (scanButtonVisible) { + var showMlKitPermissionDialog by remember { mutableStateOf(false) } + + if (showMlKitPermissionDialog) { + MlKitPermissionDialog( + onAccept = { + navController.navigate(MainNavigationScreens.Camera.path()) + showMlKitPermissionDialog = false + }, + onDecline = { + showMlKitPermissionDialog = false + } + ) + } - if (showMlKitPermissionDialog) { - MlKitPermissionDialog( - onAccept = { - navController.navigate(MainNavigationScreens.Camera.path()) - showMlKitPermissionDialog = false + // data matrix code scanner + IconButton( + onClick = { + if (!isMlKitInitialized()) { + showMlKitPermissionDialog = true + } else { + navController.navigate(MainNavigationScreens.Camera.path()) + } }, - onDecline = { - showMlKitPermissionDialog = false - } - ) - } - - // 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( - imageVector = Icons.Rounded.QrCode, - contentDescription = null, - tint = AppTheme.colors.primary700, - modifier = Modifier.size(24.dp) - ) + modifier = Modifier + .testTag("erx_btn_scn_prescription") + .semantics { contentDescription = accScan } + ) { + Icon( + imageVector = Icons.Rounded.QrCode, + contentDescription = null, + tint = AppTheme.colors.primary700, + modifier = Modifier.size(24.dp) + ) + } } }, content = { - AnimatedVisibility( - visible = tabBar != null, - enter = expandVertically(), - exit = shrinkVertically() - ) { tabBar?.invoke() } + if (tabBarVisible) { + tabBar() + } } ) } -private fun isMlKitInitialized() = +fun isMlKitInitialized() = try { MlKitContext.getInstance() true @@ -997,7 +971,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..17e5874c 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,20 +21,21 @@ 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.profiles.repository.ProfileIdentifier import de.gematik.ti.erp.app.settings.ui.SettingsScrollTo 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 Biometry : Route("Biometry") object Settings : Route( "Settings", navArgument("scrollToSection") { @@ -49,24 +50,21 @@ object MainNavigationScreens { object Camera : Route("Camera") object Prescriptions : Route("Prescriptions") 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 - } + object Orders : Route("Orders") + + object Messages : Route( + "Messages", + navArgument("orderId") { type = NavType.StringType } ) { - fun path(pickUpCodeHR: String?, pickUpCodeDMC: String?) = - path("pickUpCodeHR" to pickUpCodeHR, "pickUpCodeDMC" to pickUpCodeDMC) + fun path(orderId: String) = + Messages.path("orderId" to orderId) } object Pharmacies : Route( @@ -86,12 +84,9 @@ object MainNavigationScreens { 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 +94,14 @@ 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) } } 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 index 20bc0aeb..3092158c 100644 --- 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 @@ -19,7 +19,7 @@ package de.gematik.ti.erp.app.mainscreen.ui import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,6 +36,7 @@ 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,17 +51,20 @@ 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 com.google.accompanist.insets.systemBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding import de.gematik.ti.erp.app.R 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 @@ -88,10 +92,14 @@ fun OrderSuccessDialog( ), contentAlignment = Alignment.BottomCenter ) { + var delayedVisibility by remember { mutableStateOf(false) } + LaunchedEffect(showDialog) { + delayedVisibility = showDialog + } AnimatedVisibility( - showDialog, - enter = EnterTransition.None, - exit = slideOutVertically(targetOffsetY = { it }) + delayedVisibility, + enter = slideInVertically { it }, + exit = slideOutVertically { it } ) { Surface( modifier = Modifier @@ -105,13 +113,13 @@ fun OrderSuccessDialog( ) { Column { VideoContent( - Modifier - .fillMaxWidth(), + 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( @@ -121,13 +129,13 @@ fun OrderSuccessDialog( Text( stringResource(R.string.main_order_success_title), textAlign = TextAlign.Center, - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) SpacerSmall() Text( stringResource(R.string.main_order_success_subtitle), textAlign = TextAlign.Center, - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) TextButton( onClick = { showDialog = false }, 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 069bcba9..fd84a591 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 @@ -19,43 +19,19 @@ package de.gematik.ti.erp.app.mainscreen.ui 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 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.usecase.PrescriptionUseCase -import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import java.net.URI -import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi +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 import kotlinx.coroutines.flow.onEach -sealed class RefreshEvent { - object NetworkNotAvailable : RefreshEvent() - data class ServerCommunicationFailedWhileRefreshing(val code: Int) : RefreshEvent() - object FatalTruststoreState : RefreshEvent() - data class NewPrescriptionsEvent(val nrOfNewPrescriptions: Int) : RefreshEvent() -} - /** * Event used to indicate an action that should be visible to the user on main screen. */ @@ -63,90 +39,29 @@ sealed class ActionEvent { data class ReturnFromPharmacyOrder(val successfullyOrdered: PharmacyScreenData.OrderOption) : ActionEvent() } -enum class PullRefreshState { - None, - HasFirstTimeValidToken, - IsFirstTimeBiometricAuthentication, - HasValidToken, - DemoMode, - DemoLoggedIn -} +class MainScreenViewModel( + private val messageUseCase: OrderUseCase, + private val dispatchers: DispatchProvider +) : ViewModel() { -@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() { - - private val _onRefreshEvent = MutableSharedFlow() - val onRefreshEvent: Flow + private val _onRefreshEvent = MutableSharedFlow() + val onRefreshEvent: Flow get() = _onRefreshEvent private val _onActionEvent = MutableStateFlow(null) val onActionEvent: Flow get() = _onActionEvent.filterNotNull().onEach { _onActionEvent.value = null } - 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 { - demoUseCase.isDemoModeActive && demoUseCase.authTokenReceived.value -> PullRefreshState.DemoLoggedIn - demoUseCase.isDemoModeActive -> PullRefreshState.DemoMode - 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 - } - } - - fun redeemState(): Flow = - combine( - prescriptionUseCase.redeemableAndValidSyncedTaskIds(), - prescriptionUseCase.redeemableScannedTaskIds() - ) { syncedTaskIds, scannedTaskIds -> - MainScreenData.RedeemState( - scannedTaskIds = TaskIds(ids = scannedTaskIds), syncedTaskIds = TaskIds(ids = syncedTaskIds) - ) - } - - fun saveActiveProfile(profile: ProfilesUseCaseData.Profile) { - viewModelScope.launch { profileUseCase.switchActiveProfile(profile) } - } - - fun unreadMessagesAvailable() = - messageUseCase.unreadCommunicationsAvailable(CommunicationProfile.ErxCommunicationReply) + fun unreadMessagesAvailable(profileIdentifier: ProfileIdentifier) = + messageUseCase.unreadCommunicationsAvailable(profileIdentifier) - suspend fun onRefresh(event: RefreshEvent) { + suspend fun onRefresh(event: PrescriptionServiceState) { _onRefreshEvent.emit(event) } fun onAction(event: ActionEvent) { - viewModelScope.launch(coroutineDispatchProvider.default()) { + viewModelScope.launch(dispatchers.Default) { _onActionEvent.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()) - } - } } diff --git a/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemBottomSheetContent.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemBottomSheetContent.kt new file mode 100644 index 00000000..fe0ffb3a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemBottomSheetContent.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.navigationBarsPadding +import androidx.compose.material.icons.Icons +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.gematik.ti.erp.app.R +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.repository.ProfileIdentifier +import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData +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.annotatedPluralsResource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.shareIn +import org.kodein.di.compose.rememberViewModel + +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 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) { + combine( + redeemStateBridge.allowRedeemWithoutTiFeatureEnabled(), + redeemStateBridge.scannedTasks(profileIdentifier), + redeemStateBridge.syncedTasks(profileIdentifier) + ) { 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 +fun RedeemBottomSheetContent( + 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 + } + ) + } + + 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 +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/RedeemButton.kt b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemButton.kt new file mode 100644 index 00000000..080ab395 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RedeemButton.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.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.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)) }, + 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..c38324cf --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/mainscreen/ui/RefreshScaffold.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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, + onShowCardWall: () -> Unit, + content: @Composable (onRefresh: (isUserAction: Boolean, priority: MutatePriority) -> Unit) -> Unit +) { + val scope = rememberCoroutineScope() + val mutex = MutatorMutex() + + val refreshPrescriptionsController = rememberRefreshPrescriptionsController() + + 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 index b7495e6f..24c5a57d 100644 --- 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 @@ -37,6 +37,7 @@ 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 @@ -47,6 +48,7 @@ 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 @@ -56,6 +58,7 @@ 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); @@ -67,7 +70,7 @@ enum class PrescriptionTabs(val index: Int) { @Composable fun RedeemAndArchiveTabs( selectedTab: PrescriptionTabs, - onSelectedTab: (PrescriptionTabs) -> Unit, + onSelectedTab: (PrescriptionTabs) -> Unit ) { val tabNames = listOf(stringResource(string.mainscreen_tab_redeemable), stringResource(string.mainscreen_tab_archive)) @@ -87,6 +90,7 @@ fun TextTabRow( onClick: (index: Int) -> Unit, backGroundColor: Color, tabs: List, + testTags: (List)? = null ) { var contentWidth by remember { mutableStateOf(0) } @@ -97,18 +101,19 @@ fun TextTabRow( indicator = { tabPositions -> TabIndicator(tabPositions, selectedTabIndex, with(LocalDensity.current) { contentWidth.toDp() }) }, - divider = {}, + 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, + unselectedContentColor = AppTheme.colors.neutral500 ) { Text( text = tabText, - style = MaterialTheme.typography.subtitle2, + style = AppTheme.typography.subtitle2, modifier = Modifier .padding(top = PaddingDefaults.Small) .padding(bottom = PaddingDefaults.Small + 2.dp) @@ -131,7 +136,6 @@ private fun RedeemAndArchiveTabsPreview() { @Composable private fun TabIndicator(tabPositions: List, selectedTab: Int, contentWidth: Dp) { - val currentContentWidth by animateDpAsState( targetValue = contentWidth, animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) 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/mainscreen/ui/model/MainScreenData.kt index 0534b989..a3f69146 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/mainscreen/ui/model/MainScreenData.kt @@ -23,11 +23,21 @@ import androidx.compose.runtime.Stable import de.gematik.ti.erp.app.mainscreen.ui.TaskIds object MainScreenData { + + data class SentTask( + val taskId: String, + val medicationText: String + ) + @Immutable - data class RedeemState(val scannedTaskIds: TaskIds, val syncedTaskIds: TaskIds) { + data class RedeemState( + val scannedTaskIds: TaskIds, + val syncedTaskIds: TaskIds, + val alreadySentTaskIdsAndNames: List + ) { @Stable fun hasRedeemableTasks() = scannedTaskIds.isNotEmpty() || syncedTaskIds.isNotEmpty() } - val emptyRedeemState = RedeemState(TaskIds(emptyList()), TaskIds(emptyList())) + val emptyRedeemState = RedeemState(TaskIds(emptyList()), TaskIds(emptyList()), emptyList()) } 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 index 8564d8e7..5037c3f0 100644 --- 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 @@ -29,54 +29,68 @@ 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.PaddingValues +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.ime import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.icons.Icons.Rounded +import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check 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.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.layout.ContentScale +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.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import com.google.accompanist.insets.imePadding +import androidx.compose.ui.unit.max import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.db.entities.SettingsAuthenticationMethod.DeviceSecurity +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.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.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 java.util.Locale +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(), @@ -97,106 +111,133 @@ sealed class OnboardingSecureAppMethod { object None : OnboardingSecureAppMethod(), Parcelable } +@Immutable private enum class AuthTab(val index: Int) { Password(1), Biometric(0) } +@Composable +fun OnboardingLazyColumn( + modifier: Modifier = Modifier, + state: LazyListState, + content: LazyListScope.() -> Unit +) { + val imePadding = WindowInsets.ime.asPaddingValues() + + LazyColumn( + state = state, + modifier = modifier, + contentPadding = PaddingValues( + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + max(OnboardingFabPadding, imePadding.calculateBottomPadding()), + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ), + content = content + ) +} + @OptIn(ExperimentalAnimationApi::class) @Composable fun OnboardingSecureApp( modifier: Modifier, - isReturningUser: Boolean = false, secureMethod: OnboardingSecureAppMethod, onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit, - onNext: () -> Unit + onNext: () -> Unit, + onOpenBiometricScreen: () -> Unit ) { - val header = stringResource(R.string.on_boarding_secure_app_page_header) - val info = stringResource(R.string.on_boarding_secure_app_page_info) - + val lazyListState = rememberLazyListState() var selectedTab by remember { mutableStateOf(AuthTab.Biometric) } - Column( + OnboardingLazyColumn( + state = lazyListState, modifier = modifier - .testTag("onboarding/secureAppPage") + .visualTestTag(TestTag.Onboarding.CredentialsScreen) .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = PaddingDefaults.Large, vertical = PaddingDefaults.XXLarge) ) { - - if (isReturningUser) { + item { Image( - painterResource(R.drawable.laptop_woman_blue), - null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxSize() + painterResource(R.drawable.developer), + contentDescription = null, + alignment = Alignment.CenterStart, + modifier = Modifier + .padding( + top = PaddingDefaults.XXLarge + ) + .fillMaxWidth() ) - SpacerMedium() } - - Text( - text = header, - style = MaterialTheme.typography.h6, - color = AppTheme.colors.primary900, - textAlign = TextAlign.Center - ) - - SpacerMedium() - - Text( - text = info, - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral999, - ) - - SpacerLarge() - - TextTabRow( - selectedTabIndex = selectedTab.index, - modifier = Modifier.fillMaxWidth(), - backGroundColor = MaterialTheme.colors.background, - onClick = { - when (it) { - 0 -> { - selectedTab = AuthTab.Biometric + 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 = onNext + ) } - 1 -> { - selectedTab = AuthTab.Password + AuthTab.Biometric -> { + BiometricAuthentication( + secureMethod = secureMethod, + openBiometryScreen = onOpenBiometricScreen + ) } } - onSecureMethodChange(OnboardingSecureAppMethod.None) - }, - tabs = listOf( - stringResource(R.string.onboarding_secure_app_biometric), - stringResource(R.string.onboarding_secure_app_password) - ) - ) - - SpacerXXLarge() - - 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, - onSecureMethodChange = onSecureMethodChange, - onNext = onNext - ) - AuthTab.Biometric -> BiometricAuthentication( - secureMethod = secureMethod, - onSecureMethodChange = onSecureMethodChange - ) } } } @@ -205,9 +246,13 @@ fun OnboardingSecureApp( @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 = @@ -219,16 +264,18 @@ private fun PasswordAuthentication( (secureMethod as? OnboardingSecureAppMethod.Password)?.score ?: 0 } + val focusManager = LocalFocusManager.current + Column( - Modifier.imePadding() + modifier = Modifier.wrapContentSize() ) { - val focusRequester = FocusRequester.Default - val focusManager = LocalFocusManager.current - PasswordTextField( modifier = Modifier - .testTag("onboarding/secure_text_input_1") - .fillMaxWidth(), + .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()) { @@ -237,22 +284,28 @@ private fun PasswordAuthentication( onSecureMethodChange( OnboardingSecureAppMethod.Password( password = it, - repeatedPassword = "", + repeatedPassword = repeatedPassword, score = passwordScore ) ) } }, - onSubmit = { focusRequester.requestFocus() }, + onSubmit = { + if (checkPasswordScore(passwordScore)) { + focusManager.moveFocus(FocusDirection.Down) + } + }, allowAutofill = true, allowVisiblePassword = true, label = { - Text(stringResource(R.string.settings_password_enter_password)) + Text(stringResource(R.string.settings_password_enter)) } ) - SpacerTiny() PasswordStrength( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag(TestTag.Onboarding.Credentials.PasswordStrengthCheck) + .fillMaxWidth() + .padding(bottom = PaddingDefaults.Medium), password = password, onScoreChange = { onSecureMethodChange( @@ -264,14 +317,12 @@ private fun PasswordAuthentication( ) } ) - - SpacerMedium() - ConfirmationPasswordTextField( modifier = Modifier - .testTag("onboarding/secure_text_input_2") + .visualTestTag(TestTag.Onboarding.Credentials.PasswordFieldB) .fillMaxWidth() - .focusRequester(focusRequester), + .scrollOnFocus(POS_OF_ANIMATED_CONTENT_ITEM, lazyListState, offsetSecondPassword) + .onGloballyPositioned { offsetSecondPassword = it.positionInParent().y.toInt() }, password = password, value = repeatedPassword, passwordScore = passwordScore, @@ -289,78 +340,45 @@ private fun PasswordAuthentication( 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 + ) + ) + } } } @Composable private fun BiometricAuthentication( secureMethod: OnboardingSecureAppMethod, - onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit + openBiometryScreen: () -> Unit ) { - Column { - 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 = DeviceSecurity, - title = stringResource(R.string.auth_prompt_headline), - description = "", - negativeButton = stringResource(R.string.auth_prompt_cancel), - onAuthenticated = { - showBiometricPrompt = false - onSecureMethodChange(OnboardingSecureAppMethod.DeviceSecurity) - }, - onCancel = { - showBiometricPrompt = false - }, - onAuthenticationError = { - showBiometricPrompt = false - }, - onAuthenticationSoftError = { - } - ) - } - - val buttonColors = if (secureMethod == OnboardingSecureAppMethod.DeviceSecurity) { - ButtonDefaults.buttonColors( - backgroundColor = AppTheme.colors.green600, - contentColor = AppTheme.colors.neutral000 - ) - } else { - ButtonDefaults.buttonColors() - } - + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { LargeButton( + enabled = secureMethod != OnboardingSecureAppMethod.DeviceSecurity, onClick = { - showAcceptDeviceAuthenticationInfo = true + openBiometryScreen() }, - colors = buttonColors + colors = ButtonDefaults.buttonColors() ) { if (secureMethod == OnboardingSecureAppMethod.DeviceSecurity) { - Icon(Rounded.Check, null) + Icon(Icons.Rounded.Check, null) SpacerSmall() Text( - stringResource(R.string.onboarding_secure_app_button_best_chosen).uppercase( - Locale.getDefault() - ) + stringResource(R.string.onboarding_secure_app_button_method_chosen) ) } else { - Text(stringResource(R.string.onboarding_secure_app_button_best).uppercase(Locale.getDefault())) + Text(stringResource(R.string.onboarding_secure_app_button_choose_method)) } } SpacerSmall() @@ -368,5 +386,6 @@ private fun BiometricAuthentication( stringResource(R.string.onboarding_secure_app_button_best_info), style = AppTheme.typography.body2l ) + SpacerMedium() } } 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 5dacc53e..b15944ff 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 @@ -23,7 +23,6 @@ import androidx.annotation.FloatRange import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.SpringSpec import androidx.compose.foundation.Image @@ -43,12 +42,12 @@ 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.rememberScrollState +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +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.ExperimentalMaterialApi import androidx.compose.material.FloatingActionButton @@ -65,6 +64,9 @@ 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.ReceiptLong +import androidx.compose.material.icons.rounded.SaveAlt +import androidx.compose.material.icons.rounded.Send import androidx.compose.material.icons.rounded.Timeline import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable @@ -86,6 +88,7 @@ import androidx.compose.ui.graphics.vector.ImageVector 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 @@ -99,12 +102,12 @@ 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 androidx.compose.foundation.layout.statusBarsPadding import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.PagerDefaults @@ -113,33 +116,35 @@ import com.google.accompanist.pager.rememberPagerState 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.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.AllowBiometryScreen import de.gematik.ti.erp.app.settings.ui.SettingsViewModel -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.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.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.annotatedStringBold import de.gematik.ti.erp.app.utils.compose.annotatedStringResource 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 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 dev.chrisbanes.snapper.ExperimentalSnapperApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.time.LocalDate import java.util.Locale import kotlin.math.max import kotlin.math.roundToInt @@ -149,26 +154,29 @@ object OnboardingNavigationScreens { 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 MAX_PAGES = 5 private const val WELCOME_PAGE = 0 private const val FEATURE_PAGE = 1 +private const val SECURE_APP_PAGE = 2 +private const val ANALYTICS_PAGE = 3 +private const val TOS_AND_DATA_PAGE = 4 +private const val DELAY = 100L -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 +val OnboardingFabPadding = 128.dp @Composable fun ReturningUserSecureAppOnboardingScreen( mainNavController: NavController, - settingsViewModel: SettingsViewModel = hiltViewModel() + settingsViewModel: SettingsViewModel, + secureMethod: OnboardingSecureAppMethod, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit ) { - var secureMethod by rememberSaveable { mutableStateOf(OnboardingSecureAppMethod.None) } - val enabled = when { - secureMethod is OnboardingSecureAppMethod.DeviceSecurity -> true - secureMethod is OnboardingSecureAppMethod.Password -> (secureMethod as? OnboardingSecureAppMethod.Password)?.let { + val enabled = when (secureMethod) { + is OnboardingSecureAppMethod.DeviceSecurity -> true + is OnboardingSecureAppMethod.Password -> (secureMethod as? OnboardingSecureAppMethod.Password)?.let { it.checkedPassword != null } ?: false else -> false @@ -212,27 +220,37 @@ fun ReturningUserSecureAppOnboardingScreen( OnboardingSecureApp( Modifier.padding(innerPadding), secureMethod = secureMethod, - isReturningUser = true, - onSecureMethodChange = { - secureMethod = it - }, - onNext = {} + onSecureMethodChange = onSecureMethodChange, + onNext = {}, + onOpenBiometricScreen = { mainNavController.navigate(MainNavigationScreens.Biometry.path()) } ) } } +@OptIn(ExperimentalPagerApi::class) @Composable fun OnboardingScreen( mainNavController: NavController, - settingsViewModel: SettingsViewModel = hiltViewModel( - LocalActivity.current - ) + settingsViewModel: SettingsViewModel ) { + val state = rememberPagerState(initialPage = 0) + val scope = rememberCoroutineScope() val navController = rememberNavController() val coroutineScope = rememberCoroutineScope() var allowTracking by rememberSaveable { mutableStateOf(false) } - + var secureMethod by rememberSaveable { mutableStateOf(OnboardingSecureAppMethod.None) } val navigationMode by navController.navigationModeState(OnboardingNavigationScreens.Onboarding.route) + + val maxPages = remember(secureMethod) { + when (secureMethod) { + is OnboardingSecureAppMethod.Password -> (secureMethod as? OnboardingSecureAppMethod.Password)?.let { + if (it.checkedPassword != null) MAX_PAGES else SECURE_APP_PAGE + 1 + } ?: (SECURE_APP_PAGE + 1) + is OnboardingSecureAppMethod.DeviceSecurity -> MAX_PAGES + else -> SECURE_APP_PAGE + 1 + } + } + NavHost( navController, startDestination = OnboardingNavigationScreens.Onboarding.route @@ -241,31 +259,32 @@ fun OnboardingScreen( NavigationAnimation(mode = navigationMode) { OnboardingScreenWithScaffold( navController, + secureMethod = secureMethod, + onSecureMethodChange = { + secureMethod = it + }, + state = state, + maxPages = maxPages, allowTracking = allowTracking, onAllowTracking = { allowTracking = it }, - onSaveNewUser = { allowTracking, secureMethod, profileName -> - coroutineScope.launch { - when (secureMethod) { - is OnboardingSecureAppMethod.DeviceSecurity -> - settingsViewModel.onSelectDeviceSecurityAuthenticationMode() - is OnboardingSecureAppMethod.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()) { @@ -279,15 +298,33 @@ fun OnboardingScreen( } composable(OnboardingNavigationScreens.Analytics.route) { NavigationAnimation(mode = navigationMode) { - AllowAnalyticsScreen { - allowTracking = it - navController.popBackStack() - } + AllowAnalyticsScreen( + onBack = { navController.popBackStack() }, + onAllowAnalytics = { allowTracking = it } + ) + } + } + composable(OnboardingNavigationScreens.Biometry.route) { + NavigationAnimation(mode = navigationMode) { + AllowBiometryScreen( + onBack = { navController.popBackStack() }, + onNext = { + navController.popBackStack() + if (state.currentPage == SECURE_APP_PAGE) { + scope.launch { + delay(DELAY) // composable needs time to recalculate maxPages + state.animateScrollToPage(state.currentPage + 1) + } + } + }, + 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 @@ -297,6 +334,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 @@ -306,33 +344,25 @@ fun OnboardingScreen( } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class, ExperimentalSnapperApi::class) +@Suppress("LongMethod") +@OptIn(ExperimentalPagerApi::class, ExperimentalSnapperApi::class, ExperimentalMaterialApi::class) @Composable private fun OnboardingScreenWithScaffold( navController: NavController, + secureMethod: OnboardingSecureAppMethod, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit, allowTracking: Boolean, + maxPages: Int, + state: PagerState, onAllowTracking: (Boolean) -> Unit, - onSaveNewUser: (allowTracking: Boolean, secureAppMethod: OnboardingSecureAppMethod, 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(OnboardingSecureAppMethod.None) } - var profileName by rememberSaveable { mutableStateOf("") } - - val state = rememberPagerState(initialPage = 0) - - val maxPages = if (profileName.isBlank()) { - PROFILE_PAGE + 1 - } else - when (secureMethod) { - is OnboardingSecureAppMethod.Password -> (secureMethod as? OnboardingSecureAppMethod.Password)?.let { - if (it.checkedPassword != null) MAX_PAGES else SECURE_APP_PAGE + 1 - } ?: (SECURE_APP_PAGE + 1) - is OnboardingSecureAppMethod.DeviceSecurity -> MAX_PAGES - else -> SECURE_APP_PAGE + 1 - } - val scope = rememberCoroutineScope() BackHandler(enabled = state.currentPage > 0) { scope.launch { @@ -340,15 +370,31 @@ private fun OnboardingScreenWithScaffold( } } + val doNextPage = remember { Channel(Channel.CONFLATED) } + + LaunchedEffect(state.pageCount) { + for (ignored in doNextPage) { + if (state.currentPage + 1 < state.pageCount) { + state.animateScrollToPage(state.currentPage + 1) + } + } + } + + val focusManager = LocalFocusManager.current + LaunchedEffect(state.currentPage) { + focusManager.clearFocus() + } + Scaffold( modifier = Modifier - .testTag("screen_onboarding") + .visualTestTag("screen_onboarding") .minimalSystemBarsPadding() ) { Box { HorizontalPager( count = maxPages, modifier = Modifier + .testTag(TestTag.Onboarding.Pager) .fillMaxSize(), state = state, flingBehavior = PagerDefaults.flingBehavior( @@ -373,31 +419,17 @@ private fun OnboardingScreenWithScaffold( } ) } - PROFILE_PAGE -> { - OnboardingProfile( - modifier = Modifier.semantics { - focused = state.currentPage == PROFILE_PAGE - }, - profileName = profileName, - onProfileNameChange = { profileName = it }, - onNext = { - scope.launch { - state.animateScrollToPage(state.currentPage + 1) - } - } - ) - } + SECURE_APP_PAGE -> { OnboardingSecureApp( Modifier.semantics { focused = state.currentPage == SECURE_APP_PAGE }, secureMethod = secureMethod, - onSecureMethodChange = { - secureMethod = it + onSecureMethodChange = onSecureMethodChange, + onOpenBiometricScreen = { + navController.navigate(OnboardingNavigationScreens.Biometry.path()) }, onNext = { - scope.launch { - state.animateScrollToPage(state.currentPage + 1) - } + scope.launch { doNextPage.send(Unit) } } ) } @@ -419,53 +451,62 @@ private fun OnboardingScreenWithScaffold( TOS_AND_DATA_PAGE -> { OnboardingPageTerms( Modifier.semantics { focused = state.currentPage == TOS_AND_DATA_PAGE }, - navController, + navController ) { tosAndDataToggled = it } } } } - BottomPageIndicator(state) - - OnboardingNextButton( - Modifier.testId("onb_btn_next"), - profileName, - secureMethod, - tosAndDataToggled, - currentPage = state.currentPage, - onNextPage = { - scope.launch { - state.animateScrollToPage(state.currentPage + 1) + val currentPageIsSecureApp = state.currentPage == SECURE_APP_PAGE + val secureMethodIsEmpty = secureMethod is OnboardingSecureAppMethod.None || + (secureMethod is OnboardingSecureAppMethod.Password && secureMethod.checkedPassword == null) + val defaultProfileName = stringResource(R.string.onboarding_default_profile_name) + if (!(currentPageIsSecureApp && secureMethodIsEmpty)) { + OnboardingNextButton( + secureMethod = secureMethod, + tosAndDataToggled = tosAndDataToggled, + currentPage = state.currentPage, + onNextPage = { + scope.launch { doNextPage.send(Unit) } + }, + onSaveNewUser = { + onSaveNewUser(allowTracking, defaultProfileName, secureMethod) } - }, - onSaveNewUser = { - onSaveNewUser(allowTracking, secureMethod, profileName) - } - ) + ) + } if (BuildKonfig.INTERNAL) { - OutlinedDebugButton( - "SKIP", - onClick = { - onSaveNewUser(false, OnboardingSecureAppMethod.Password("a", "a", 9), DEFAULT_PROFILE_NAME) - }, + Row( modifier = Modifier .align(Alignment.BottomStart) .padding(PaddingDefaults.Medium) - ) + ) { + OutlinedDebugButton( + "SKIP", + onClick = { + onSaveNewUser(false, defaultProfileName, OnboardingSecureAppMethod.Password("a", "a", 9)) + } + ) + } } } } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class) +@OptIn(ExperimentalPagerApi::class) @Composable private fun BoxScope.BottomPageIndicator(pagerState: PagerState) { + val pagerAccessibilityDescription = annotatedStringResource( + R.string.on_boarding_pager_acc_description, + (pagerState.currentPage + 1).toString(), + MAX_PAGES.toString() + ).toString() Box( modifier = Modifier .padding(bottom = 24.dp) + .semantics { contentDescription = pagerAccessibilityDescription } .clip(CircleShape) .background(AppTheme.colors.neutral100.copy(alpha = 0.5f)) .align(Alignment.BottomCenter) @@ -499,26 +540,23 @@ private fun Dot(modifier: Modifier = Modifier, color: Color) { ) } -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) @Composable private fun BoxScope.OnboardingNextButton( modifier: Modifier = Modifier, - profileName: String, - secureMethod: OnboardingSecureAppMethod, tosAndDataToggled: Boolean, + secureMethod: OnboardingSecureAppMethod, 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 OnboardingSecureAppMethod.DeviceSecurity -> true currentPage == SECURE_APP_PAGE && secureMethod is OnboardingSecureAppMethod.Password -> secureMethod.checkedPassword != null tosAndDataToggled && currentPage == TOS_AND_DATA_PAGE -> true else -> false } - + val onBoardingNextButton = stringResource(R.string.on_boarding_cdn_btn_next) NextButton( onNext = { when { @@ -529,7 +567,15 @@ private fun BoxScope.OnboardingNextButton( } }, enabled = enabled, - modifier = modifier.align(Alignment.BottomEnd) + modifier = modifier + .align(Alignment.BottomEnd) + .semantics { + contentDescription = if (currentPage != TOS_AND_DATA_PAGE) { + onBoardingNextButton + } else { + "" + } + } ) { Crossfade(targetState = currentPage == TOS_AND_DATA_PAGE) { when (it) { @@ -543,16 +589,13 @@ private fun BoxScope.OnboardingNextButton( Row { Spacer4() Text( - stringResource(R.string.on_boarding_page_4_next).uppercase( - Locale.getDefault() - ) + stringResource(R.string.on_boarding_page_4_next) ) } } } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) @Composable private fun NextButton( modifier: Modifier = Modifier, @@ -562,7 +605,7 @@ private fun NextButton( ) { val backgroundColor = if (enabled) { - MaterialTheme.colors.secondary + AppTheme.colors.primary600 } else { AppTheme.colors.neutral300 } @@ -570,7 +613,7 @@ private fun NextButton( val contentColor = if (enabled) { contentColorFor(backgroundColor) } else { - AppTheme.colors.neutral500 + AppTheme.colors.neutral600 } FloatingActionButton( @@ -579,16 +622,20 @@ private fun NextButton( onNext() } }, + shape = RoundedCornerShape(PaddingDefaults.Medium), backgroundColor = backgroundColor, contentColor = contentColor, modifier = modifier - .testTag("onboarding/next") - .padding(bottom = 64.dp, end = 24.dp) + .padding( + bottom = 64.dp, + end = PaddingDefaults.Medium + ) .semantics { if (!enabled) { disabled() } } + .visualTestTag(TestTag.Onboarding.NextButton) ) { Row( modifier = Modifier.padding(PaddingDefaults.Medium), @@ -619,65 +666,77 @@ private fun PeopleLayer( ) { Image( painterResource(R.drawable.onboarding_boygrannygranpa), - stringResource(R.string.on_boarding_page_1_acc_image), + null, alignment = Alignment.BottomStart, modifier = Modifier.fillMaxSize() ) } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class) +@OptIn(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 + .visualTestTag(TestTag.Onboarding.WelcomeScreen) + .padding(horizontal = PaddingDefaults.Medium) ) { 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( + 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 + ) } - - 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, + Column( modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 56.dp) - ) + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + 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 + ) + ) + } val offset by derivedStateOf { if (pagerState.currentPage == WELCOME_PAGE) pagerState.currentPageOffset else 0f } @@ -689,54 +748,78 @@ private fun OnboardingWelcome(modifier: Modifier, pagerState: PagerState) { } @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( +private fun OnboardingAppFeatures( + modifier: Modifier +) { + OnboardingLazyColumn( + state = rememberLazyListState(), modifier = modifier - .testTag("onboarding/features") + .visualTestTag(TestTag.Onboarding.FeatureScreen) .fillMaxSize() - .verticalScroll(rememberScrollState()) ) { - Image( - image, - imageAcc, - alignment = Alignment.Center, - 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) - ) { - 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)) + item { + Image( + painter = painterResource(R.drawable.woman_red_shirt_overlapping), + null, + alignment = Alignment.CenterStart, + modifier = Modifier + .padding( + top = PaddingDefaults.XXLarge, + bottom = PaddingDefaults.XXLarge + ) + .fillMaxWidth() + ) + } + item { + Column( + modifier = Modifier + .wrapContentSize() + .semantics(mergeDescendants = true) {} + ) { + Text( + text = stringResource(R.string.onb_page_3_header), + style = AppTheme.typography.h4, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Start + ) + Column( + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), + modifier = Modifier.padding( + top = PaddingDefaults.Medium + ) + ) { + OnboardingFeatureItem( + icon = Icons.Rounded.Send, + text = stringResource(R.string.onb_page_3_info_check_1) + ) + OnboardingFeatureItem( + icon = Icons.Rounded.ReceiptLong, + text = stringResource(R.string.on_boarding_page_3_info_check_2) + ) + OnboardingFeatureItem( + icon = Icons.Rounded.SaveAlt, + text = stringResource(R.string.on_boarding_page_3_info_check_3) + ) + } + } } } } @Composable -private fun OnboardingCheck(text: String) { +private fun OnboardingFeatureItem(icon: ImageVector, text: String) { Row { - Icon(Icons.Rounded.CheckCircle, null, tint = AppTheme.colors.green600) - Spacer16() - Text(text, style = MaterialTheme.typography.body1) + Icon( + icon, + null, + tint = AppTheme.colors.primary600 + ) + SpacerMedium() + Text( + text, + style = AppTheme.typography.body1, + textAlign = TextAlign.Start + ) } } @@ -746,64 +829,68 @@ private fun OnboardingPageAnalytics( allowTracking: Boolean, onAllowTracking: (Boolean) -> Unit ) { - val header = stringResource(R.string.on_boarding_page_5_header) - val subHeader = stringResource(R.string.on_boarding_page_5_sub_header) - - Column( + OnboardingLazyColumn( + state = rememberLazyListState(), modifier = modifier - .testTag("onboarding/analytics") + .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 { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + 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 + ) ) - ) - 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) - ) + 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 + ) + SpacerMedium() + AnalyticsInfo( + icon = Icons.Rounded.BugReport, + id = R.string.on_boarding_page_5_info_2, + stringBold = stringBold + ) + SpacerMedium() + AnalyticsInfo( + icon = Icons.Rounded.LiveHelp, + id = R.string.on_boarding_page_5_info_3, + stringBold = "" + ) + } + } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + SpacerXXLarge() + AnalyticsToggle(allowTracking, onAllowTracking) + SpacerSmall() + Text( + stringResource(R.string.on_boarding_page_5_label_info), + style = AppTheme.typography.body2l, + textAlign = TextAlign.Center + ) + } + } } } @@ -811,57 +898,64 @@ private fun OnboardingPageAnalytics( private fun OnboardingPageTerms( modifier: Modifier, navController: NavController, - onBothToggled: (Boolean) -> Unit, + onBothToggled: (Boolean) -> Unit ) { - val header = stringResource(R.string.on_boarding_page_4_header) - val info = stringResource(R.string.on_boarding_page_4_info) - - Column( + OnboardingLazyColumn( + state = rememberLazyListState(), modifier = modifier - .testTag("onboarding/terms") + .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 { + Image( + painter = painterResource(R.drawable.paragraph), + contentDescription = null, + alignment = Alignment.CenterStart, + modifier = Modifier + .padding( + top = PaddingDefaults.XXLarge + ) + .fillMaxWidth() + ) } + item { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + 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) + ) + Text( + text = stringResource(R.string.on_boarding_page_4_info), + style = AppTheme.typography.body1, + modifier = Modifier + .padding(bottom = PaddingDefaults.XXLarge) + ) + } + var checkedDataProtection by rememberSaveable { mutableStateOf(false) } + var checkedTos by rememberSaveable { mutableStateOf(false) } - Spacer24() + DisposableEffect(checkedDataProtection, checkedTos) { + if (checkedDataProtection && checkedTos) { + onBothToggled(true) + } else { + onBothToggled(false) + } + onDispose { } + } - Column( - modifier = Modifier.padding( - start = 24.dp, - end = 24.dp, - bottom = 136.dp - ) - ) { OnboardingToggle( stringResource(R.string.on_boarding_page_4_info_dataprotection), stringResource(R.string.onb_accept_data), - toggleTestId = "onb_btn_accept_privacy", + toggleTestTag = TestTag.Onboarding.DataTerms.DataProtectionSwitch, + clickTestTag = TestTag.Onboarding.DataTerms.OpenDataProtectionButton, checked = checkedDataProtection, onCheckedChange = { checkedDataProtection = it @@ -870,10 +964,12 @@ private fun OnboardingPageTerms( 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", + toggleTestTag = TestTag.Onboarding.DataTerms.TermsOfUseSwitch, + clickTestTag = TestTag.Onboarding.DataTerms.OpenTermsOfUseButton, checked = checkedTos, onCheckedChange = { checkedTos = it @@ -889,17 +985,16 @@ private fun OnboardingPageTerms( @Composable private fun AnalyticsInfo(icon: ImageVector, @StringRes id: Int, stringBold: String) { Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(horizontal = PaddingDefaults.Large) + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium) ) { - Icon(icon, null, tint = AppTheme.colors.primary500) + Icon(icon, null, tint = AppTheme.colors.primary600) Column(modifier = Modifier.weight(1.0f)) { Text( text = annotatedStringResource( id, annotatedStringBold(stringBold) ), - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) } } @@ -910,13 +1005,10 @@ private fun AnalyticsToggle( analyticsAllowed: Boolean, onCheckedChange: (Boolean) -> Unit ) { - val labelText = stringResource(R.string.on_boarding_page_5_label) - Row( modifier = Modifier - .padding(horizontal = PaddingDefaults.Large) - .clip(RoundedCornerShape(16.dp)) - .background(AppTheme.colors.neutral100, shape = RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(PaddingDefaults.Medium)) + .background(AppTheme.colors.neutral100, shape = RoundedCornerShape(PaddingDefaults.Medium)) .fillMaxWidth() .toggleable( value = analyticsAllowed, @@ -926,20 +1018,19 @@ private fun AnalyticsToggle( 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, + text = stringResource(R.string.on_boarding_page_5_label), + style = AppTheme.typography.subtitle2, modifier = Modifier.weight(1f) ) SpacerSmall() Switch( checked = analyticsAllowed, - onCheckedChange = null, + onCheckedChange = null ) } } @@ -948,7 +1039,8 @@ private fun AnalyticsToggle( private fun OnboardingToggle( which: String, toggleContentDescription: String, - toggleTestId: String, + toggleTestTag: String, + clickTestTag: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, onClickInfo: () -> Unit @@ -956,15 +1048,16 @@ private fun OnboardingToggle( Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 24.dp), + .semantics(mergeDescendants = true) {} + .padding(bottom = PaddingDefaults.Large), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { val info = annotatedStringResource( R.string.on_boarding_page_4_info_accept_info, buildAnnotatedString { pushStringAnnotation("CLICKABLE", "") - pushStyle(SpanStyle(color = AppTheme.colors.primary500)) + pushStyle(SpanStyle(color = AppTheme.colors.primary600)) append(which) pop() pop() @@ -983,7 +1076,7 @@ private fun OnboardingToggle( Text( text = info, - style = MaterialTheme.typography.body1, + style = AppTheme.typography.body1, modifier = Modifier .weight(1f) .clickable( @@ -992,6 +1085,7 @@ private fun OnboardingToggle( interactionSource = remember { MutableInteractionSource() }, onClick = onClickInfo ) + .visualTestTag(clickTestTag) ) Box( @@ -1005,22 +1099,23 @@ private fun OnboardingToggle( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple( bounded = false, - radius = 24.dp + radius = PaddingDefaults.Large ) ) - .testTag(toggleTestId) - .testId(toggleTestId) + .visualTestTag(toggleTestTag) .semantics { contentDescription = toggleContentDescription }, contentAlignment = Alignment.Center ) { Icon( - Icons.Rounded.RadioButtonUnchecked, null, - tint = AppTheme.colors.neutral400 + Icons.Rounded.RadioButtonUnchecked, + null, + tint = AppTheme.colors.neutral300 ) Icon( - Icons.Rounded.CheckCircle, null, + 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 index 9e6f4825..1d9fb5af 100644 --- 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 @@ -17,18 +17,16 @@ */ package de.gematik.ti.erp.app.onboarding.ui - +import androidx.compose.foundation.Image 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.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.rememberLazyListState 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 @@ -36,24 +34,26 @@ 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.Alignment 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.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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.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.InputField -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 +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.visualTestTag import de.gematik.ti.erp.app.utils.sanitizeProfileName -@OptIn(ExperimentalComposeUiApi::class) @Composable fun OnboardingProfile( modifier: Modifier = Modifier, @@ -62,80 +62,107 @@ fun OnboardingProfile( onProfileNameChange: (String) -> Unit, onNext: () -> Unit ) { - val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() - val header = stringResource(R.string.onboarding_profile_header) - val info = stringResource(R.string.onboarding_profile_info) + var profileNameError by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current - Column( + OnboardingLazyColumn( + state = lazyListState, modifier = modifier - .testTag("onboarding/profilePage") + .visualTestTag(TestTag.Onboarding.ProfileScreen) .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 - - InputField( - modifier = Modifier - .testTag("onboarding/profile_text_input") - .fillMaxWidth() - .focusRequester(focusRequester), - value = profileName, - onValueChange = { - val name = sanitizeProfileName(it.trimStart()) - onProfileNameChange(name) - profileNameError = name.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) + item { + Image( + painter = painterResource(R.drawable.doctor_blue_circle), + contentDescription = null, + alignment = Alignment.CenterStart, + modifier = Modifier + .padding( + top = PaddingDefaults.XXLarge + ) + .fillMaxWidth() ) - 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()) + item { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + Text( + text = stringResource(R.string.onboarding_profile_header), + style = AppTheme.typography.h4, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Start, + modifier = Modifier.padding(bottom = PaddingDefaults.Medium, top = PaddingDefaults.XXLarge) + ) + Text( + text = stringResource(R.string.onboarding_profile_information), + style = AppTheme.typography.body1l, + textAlign = TextAlign.Start, + modifier = Modifier.padding(bottom = PaddingDefaults.XLarge) + ) + } + } + item { + val inputDescription = stringResource(R.string.on_boarding_profil_input_description) + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .semantics(mergeDescendants = true) {} + ) { + InputField( + modifier = Modifier + .visualTestTag(TestTag.Onboarding.Profile.ProfileField) + .fillMaxWidth() + .scrollOnFocus(2, lazyListState) + .semantics { contentDescription = inputDescription }, + value = profileName, + onValueChange = { + val name = sanitizeProfileName(it.trimStart()) + onProfileNameChange(name) + profileNameError = name.isEmpty() + }, + onSubmit = { + if (!profileNameError) { + focusManager.clearFocus() + onNext() + } + }, + label = { + Text(stringResource(R.string.onboarding_profile_input_name)) + }, + isError = profileNameError, + colors = TextFieldDefaults.outlinedTextFieldColors() + ) + if (profileNameError) { + SpacerSmall() + Text( + text = stringResource(R.string.edit_profile_empty_profile_name), + color = AppTheme.colors.red600, + style = AppTheme.typography.caption1, + modifier = Modifier.padding( + start = PaddingDefaults.Medium + ) + ) + } + } + } + item { + if (isReturningUser) { + SpacerXLarge() + 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/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 cf1e874d..5ded4b04 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,15 +18,21 @@ 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.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 @@ -37,7 +43,6 @@ 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.MaterialTheme import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Text @@ -50,31 +55,31 @@ import androidx.compose.material.icons.rounded.ArrowRight import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember 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.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.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 @@ -89,12 +94,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 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 @@ -118,7 +124,8 @@ fun HealthCardContactOrderScreen( topBarTitle = title, elevated = listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0, navigationMode = NavigationBarMode.Close, - onBack = onBack + onBack = onBack, + actions = {} ) { HealthCardOrder( listState = listState, @@ -134,21 +141,23 @@ fun HealthCardContactOrderScreen( NavigationAnimation(mode = navigationMode) { AnimatedElevationScaffold( + 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) - navController.popBackStack() - } - ) - } + onBack = { navController.popBackStack() }, + content = { + HealthInsuranceSelector( + state = listState, + insuranceCompanies = state.companies, + selected = state.selectedCompany, + onSelectionChange = { + healthCardOrderViewModel.onSelectInsuranceCompany(it) + navController.popBackStack() + } + ) + }, + actions = {} + ) } } } @@ -167,7 +176,7 @@ private fun HealthInsuranceSelectorPreview() { subjectCardAndPinMail = null, bodyCardAndPinMail = null, subjectPinMail = null, - bodyPinMail = null, + bodyPinMail = null ) } AppTheme { @@ -190,10 +199,7 @@ private fun HealthInsuranceSelector( LazyColumn( state = state, modifier = Modifier.fillMaxSize(), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() ) { items(insuranceCompanies) { company -> HealthInsuranceCompanySelectable( @@ -222,7 +228,7 @@ private fun HealthInsuranceCompanySelectable( .padding(PaddingDefaults.Medium), 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) @@ -249,27 +255,23 @@ private fun HealthCardOrder( LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() ) { - item { Column(Modifier.padding(PaddingDefaults.Medium)) { - Text( stringResource(R.string.cdw_health_insurance_title), - style = MaterialTheme.typography.h5, - textAlign = TextAlign.Center + style = AppTheme.typography.h5, + textAlign = TextAlign.Center, + fontWeight = FontWeight.W700 ) SpacerLarge() Text( stringResource(R.string.cdw_health_insurance_body_what_you_need), - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) SpacerSmall() - Text(stringResource(R.string.cdw_health_insurance_body_how_to_get), style = MaterialTheme.typography.body1) + Text(stringResource(R.string.cdw_health_insurance_body_how_to_get), style = AppTheme.typography.body1) SpacerSmall() Text( stringResource(R.string.cdw_health_insurance_caption_recognize_healthcard), @@ -290,7 +292,7 @@ private fun HealthCardOrder( Text( stringResource(R.string.cdw_health_insurance_select_company), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) ) SpacerMedium() @@ -314,7 +316,7 @@ private fun HealthCardOrder( } else { Text( stringResource(R.string.cdw_health_insurance_what_to_do), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) ) @@ -328,12 +330,13 @@ private fun HealthCardOrder( 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 ) } } @@ -389,12 +392,14 @@ private fun Option( 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, + selected = selected, + enabled = enabled, + onClick = onSelect, colors = RadioButtonDefaults.colors( selectedColor = AppTheme.colors.primary600, unselectedColor = AppTheme.colors.neutral400, @@ -415,16 +420,18 @@ private fun ContactInsurance( url = company.healthCardAndPinUrl, mail = company.healthCardAndPinMail, company = company, - option = option, + option = option ) } if (option == HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly) { ContactMethodRow( phone = null, url = company.pinUrl, - mail = company.healthCardAndPinMail, + mail = if (company.hasMailContentForPin()) { + company.healthCardAndPinMail + } else { null }, company = company, - option = option, + option = option ) } } @@ -466,6 +473,7 @@ private fun ContactMethodRow( ) } mail?.let { + val context = LocalContext.current ContactMethod( modifier = Modifier.weight(1f), name = "Mail", @@ -473,12 +481,12 @@ private fun ContactMethodRow( onClick = { when { option == HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin && - company.hasMailContentForCardAndPin() -> uriHandler.openUri("mailto:$it?subject=${company.subjectCardAndPinMail}&body=${company.bodyCardAndPinMail}") + company.hasMailContentForCardAndPin() -> openMailClient(context = context, address = it, subject = company.subjectCardAndPinMail!!, body = company.bodyCardAndPinMail!!) option == HealthCardOrderViewModelData.ContactInsuranceOption.PinOnly && - company.hasMailContentForPin() -> uriHandler.openUri("mailto:$it?subject=${company.subjectPinMail}&body=${company.bodyPinMail}") + company.hasMailContentForPin() -> openMailClient(context = context, address = it, subject = company.subjectPinMail!!, body = company.bodyPinMail!!) - else -> uriHandler.openUri("mailto:$it?subject=$mailSubject") + else -> uriHandler.openUri("mailto:$it?subject=${Uri.encode(mailSubject)}") } } ) @@ -495,18 +503,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 7343549f..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.WithHealthCardAndPin, + selectedOption = HealthCardOrderViewModelData.ContactInsuranceOption.WithHealthCardAndPin ) private val state = MutableStateFlow(defaultState) 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 0e12bea1..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 @@ -26,7 +26,7 @@ object HealthCardOrderViewModelData { data class State( val companies: List, val selectedCompany: HealthCardOrderUseCaseData.HealthInsuranceCompany?, - val selectedOption: ContactInsuranceOption, + val selectedOption: ContactInsuranceOption ) enum class ContactInsuranceOption { 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 6c5a32d6..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,19 +19,15 @@ package de.gematik.ti.erp.app.orderhealthcard.usecase import android.content.Context -import com.squareup.moshi.FromJson -import com.squareup.moshi.JsonReader -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -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 { loadHealthInsuranceContactsFromJSON( @@ -44,31 +40,5 @@ class HealthCardOrderUseCase @Inject constructor( } } -fun loadHealthInsuranceContactsFromJSON(jsonInput: InputStream): List { - val type = Types.newParameterizedType( - List::class.java, - HealthCardOrderUseCaseData.HealthInsuranceCompany::class.java - ) - val moshiAdapter = Moshi.Builder().add(EmptyStringToNullAdapter).build().adapter>(type) - return moshiAdapter.fromJson(jsonInput.bufferedReader().readText()) as List -} - -object EmptyStringToNullAdapter { - @FromJson - fun fromJson(reader: JsonReader): String? { - return when (reader.peek()) { - JsonReader.Token.STRING -> { - val nextString = reader.nextString() - if (nextString.equals("")) { - null - } else { - nextString - } - } - JsonReader.Token.NUMBER -> { - error("${reader.nextLong()} was not a string") - } - else -> null - } - } -} +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 333768c0..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,11 +19,11 @@ package de.gematik.ti.erp.app.orderhealthcard.usecase.model import androidx.compose.runtime.Immutable -import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable object HealthCardOrderUseCaseData { @Immutable - @JsonClass(generateAdapter = true) + @Serializable data class HealthInsuranceCompany( val name: String, val healthCardAndPinPhone: String?, @@ -33,7 +33,7 @@ object HealthCardOrderUseCaseData { val subjectCardAndPinMail: String?, val bodyCardAndPinMail: String?, val subjectPinMail: String?, - val bodyPinMail: String?, + val bodyPinMail: String? ) { fun noContactInformation() = healthCardAndPinPhone.isNullOrEmpty() && @@ -42,7 +42,7 @@ object HealthCardOrderUseCaseData { pinUrl.isNullOrEmpty() fun hasContactInfoForPin() = - !pinUrl.isNullOrEmpty() + !pinUrl.isNullOrEmpty() || (!bodyPinMail.isNullOrEmpty() && !subjectPinMail.isNullOrEmpty()) fun hasContactInfoForHealthCardAndPin() = !healthCardAndPinPhone.isNullOrEmpty() || !healthCardAndPinMail.isNullOrEmpty() || !healthCardAndPinUrl.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..a94efa11 --- /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()) } + 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..668f8857 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationLocalDataSource.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT 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.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 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): Flow = + realm.query( + orQuerySubstring("parent.taskId", taskIds.size), + *taskIds.toTypedArray() + ) + .query("consumed = false") + .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)?.let { + it.consumed = consumed + } + } + } +} 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..8a5416c0 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/repository/CommunicationRepository.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.orders.repository + +import de.gematik.ti.erp.app.DispatchProvider +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.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.withContext + +class CommunicationRepository( + private val taskLocalDataSource: LocalDataSource, + private val communicationLocalDataSource: CommunicationLocalDataSource, + private val cacheLocalDataSource: PharmacyCacheLocalDataSource, + private val cacheRemoteDataSource: PharmacyCacheRemoteDataSource, + private val dispatchers: DispatchProvider +) { + 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) + } + } + } + } + + 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) = + communicationLocalDataSource.hasUnreadMessages(taskIds).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..67b9571c --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/MessageSheets.kt @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.cardwall.ui.PrimaryButtonSmall +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.SpacerLarge +import de.gematik.ti.erp.app.utils.compose.SpacerMedium +import de.gematik.ti.erp.app.utils.compose.SpacerSmall +import java.time.Instant +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.sentOn), + style = AppTheme.typography.caption1l, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.End) + ) + } +} + +@Composable +private fun sentOn(instant: Instant): String = + remember { + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + dateFormatter.format(LocalDateTime.ofInstant(instant, 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..825dd0b6 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/orders/ui/OrderScreen.kt @@ -0,0 +1,715 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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.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.res.stringResource +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.mainscreen.ui.MainNavigationScreens +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.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.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 +) { + 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 }, + onShowCardWall = onShowCardWall + ) { onRefresh -> + Orders( + profileHandler = profileHandler, + onClickOrder = { orderId -> + mainNavController.navigate( + MainNavigationScreens.Messages.path(orderId) + ) + }, + onClickRefresh = { + onRefresh(true, MutatePriority.UserInput) + } + ) + } +} + +@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( + 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 +) { + val activeProfile = profileHandler.activeProfile + val orderState = rememberOrderState(activeProfile.id) + val orders by orderState.orders + + LazyColumn(Modifier.fillMaxSize()) { + 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(), + 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( + stringResource(R.string.orders_label_nr_of_prescriptions, count), + 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(), + 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 { + Column( + Modifier.padding(PaddingDefaults.Medium), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) + ) { + it.medicationNames.forEachIndexed { index, med -> + Surface( + 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..f0a41f69 --- /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).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..cc14224d --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/PharmacyModule.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.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.OftenUsedPharmaciesUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.PharmacyDirectRedeemUseCase +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 { PharmacySearchUseCase(instance(), instance(), instance(), instance(), instance()) } + bindProvider { OftenUsedPharmaciesUseCase(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..52dcdcf5 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/model/PharmacyData.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.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 OftenUsedPharmacyData { + data class OftenUsedPharmacy( + val lastUsed: Instant, + 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..1ee9beb4 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/PharmacyLocalDataSource.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.pharmacy.repository + +import de.gematik.ti.erp.app.db.entities.v1.SettingsEntityV1 +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.OftenUsedPharmacyData +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import io.realm.kotlin.Realm +import io.realm.kotlin.ext.query +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 deleteOftenUsedPharmacy(oftenUsedPharmacy: OftenUsedPharmacyData.OftenUsedPharmacy) = + realm.tryWrite { + queryFirst("telematikId = $0", oftenUsedPharmacy.telematikId)?.let { delete(it) } + } + + fun loadOftenUsedPharmacies(): Flow> = + realm.query() + .first() + .asFlow() + .map { profile -> + profile.obj?.let { + it.oftenUsedPharmacies.map { pharmacyEntity -> + pharmacyEntity.toOftenUsedPharmacy() + } + } ?: emptyList() + } + + suspend fun saveOrUpdateOftenUsedPharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) { + realm.tryWrite { + queryFirst("telematikId = $0", pharmacy.telematikId)?.apply { + this.lastUsed = Instant.now().toRealmInstant() + this.usageCount += 1 + } ?: this.queryFirst()?.let { settings -> + settings.oftenUsedPharmacies += copyToRealm(pharmacy.toOftenUsedPharmacyEntityV1()) + } + } + } +} + +fun PharmacyUseCaseData.Pharmacy.toOftenUsedPharmacyEntityV1() = + OftenUsedPharmacyEntityV1().apply { + this.address = this@toOftenUsedPharmacyEntityV1.removeLineBreaksFromAddress() + this.lastUsed = Instant.now().toRealmInstant() + this.pharmacyName = this@toOftenUsedPharmacyEntityV1.name + this.usageCount = 1 + this.telematikId = this@toOftenUsedPharmacyEntityV1.telematikId + } + +fun OftenUsedPharmacyEntityV1.toOftenUsedPharmacy() = + OftenUsedPharmacyData.OftenUsedPharmacy( + lastUsed = this.lastUsed.toInstant(), + usageCount = this.usageCount, + telematikId = this.telematikId, + pharmacyName = this.pharmacyName, + address = this.address + ) 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 a0278e53..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,27 +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.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 30aac9fc..7bbcb7f7 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,29 +18,96 @@ package de.gematik.ti.erp.app.pharmacy.repository -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.OftenUsedPharmacyData +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 { - 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 { PharmacyMapper.extractLocalPharmacyServices(it) } + ).map { + 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) + + suspend fun saveOrUpdateOftenUsedPharmacy(pharmacy: PharmacyUseCaseData.Pharmacy) = + withContext(dispatchProvider.IO) { + localDataSource.saveOrUpdateOftenUsedPharmacy(pharmacy) + } + + suspend fun deleteOftenUsedPharmacy(oftenUsedPharmacy: OftenUsedPharmacyData.OftenUsedPharmacy) = + withContext(dispatchProvider.IO) { + localDataSource.deleteOftenUsedPharmacy(oftenUsedPharmacy) + } + + suspend fun searchPharmacyByTelematikId( + telematikId: String + ): Result = + withContext(dispatchProvider.IO) { + remoteDataSource.searchPharmacyByTelematikId(telematikId) + .map { + extractPharmacyServices( + bundle = it, + onError = { element, cause -> + Napier.e(element.toString(), cause) + } + ) + } + } } 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 index 444b3283..2a4dca9b 100644 --- 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 @@ -18,20 +18,62 @@ package de.gematik.ti.erp.app.pharmacy.repository -import de.gematik.ti.erp.app.db.AppDatabase -import de.gematik.ti.erp.app.db.entities.ShippingContactEntity +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 -import javax.inject.Inject - -class ShippingContactRepository @Inject constructor( - private val db: AppDatabase, +class ShippingContactRepository( + private val dispatchers: DispatchProvider, + private val realm: Realm ) { - fun shippingContact(): Flow> { - return db.shippingContactDao().shippingContactFlow() - } + fun shippingContact(): Flow = + realm.query() + .first() + .asFlow() + .map { + it.obj?.shippingContact?.toShippingContact() + } + .flowOn(dispatchers.IO) - suspend fun insertShippingContact(contact: ShippingContactEntity) { - db.shippingContactDao().insertShippingContact(contact) + 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 974640d8..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,9 +18,9 @@ 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, 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/OftenUsedPharmaciesViewModel.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/OftenUsedPharmaciesViewModel.kt new file mode 100644 index 00000000..f3ecc686 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/repository/model/OftenUsedPharmaciesViewModel.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.repository.model + +import androidx.lifecycle.ViewModel +import de.gematik.ti.erp.app.pharmacy.model.OftenUsedPharmacyData +import de.gematik.ti.erp.app.pharmacy.usecase.OftenUsedPharmaciesUseCase +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class OftenUsedPharmaciesViewModel( + private val pharmacyUseCase: OftenUsedPharmaciesUseCase +) : ViewModel() { + fun oftenUsedPharmaciesState() = + pharmacyUseCase.oftenUsedPharmacies().map { it.sortedByDescending { it.lastUsed } } + + suspend fun deleteOftenUsedPharmacy(oftenUsedPharmacy: OftenUsedPharmacyData.OftenUsedPharmacy) = + pharmacyUseCase.deleteOftenUsedPharmacy(oftenUsedPharmacy) + + suspend fun findPharmacyByTelematikIdState( + telematikId: String + ) = flowOf(pharmacyUseCase.searchPharmacyByTelematikId(telematikId)) +} 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 index 49d18d38..0e4216eb 100644 --- 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 @@ -18,37 +18,47 @@ 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.getValue import androidx.compose.runtime.mutableStateOf 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.Modifier import androidx.compose.ui.composed import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.onFocusEvent +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 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.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 @@ -57,36 +67,36 @@ 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 +@Suppress("LongMethod") @Composable fun EditShippingContactScreen( navController: NavController, - taskIds: List, viewModel: PharmacySearchViewModel ) { val listState = rememberLazyListState() - val state by viewModel.orderScreenState(taskIds).collectAsState(PharmacyScreenData.defaultOrderState) + val state by viewModel.orderScreenState().collectAsState(PharmacyScreenData.defaultOrderState) - var contact by rememberSaveable(state.contact) { mutableStateOf(state.contact) } - val originalContact by rememberSaveable(contact != null) { mutableStateOf(contact) } + var contact by remember(state.contact) { mutableStateOf(state.contact) } var showBackAlert by remember { mutableStateOf(false) } - val telephoneError = remember(contact?.telephoneNumber) { !isPhoneValid(contact?.telephoneNumber) } - val nameError = remember(contact?.name) { contact?.name?.isBlank() ?: true } - val line1Error = remember(contact?.line1) { contact?.line1?.isBlank() ?: true } - val codeAndCityError = remember(contact?.postalCodeAndCity) { contact?.postalCodeAndCity?.isBlank() ?: true } - val mailError = remember(contact?.mail) { !isMailValid(contact?.mail) } + val telephoneError = remember(contact.telephoneNumber) { !isPhoneValid(contact.telephoneNumber) } + val nameError = remember(contact.name) { contact.name.isBlank() } + val line1Error = remember(contact.line1) { contact.line1.isBlank() } + val codeAndCityError = remember(contact.postalCodeAndCity) { contact.postalCodeAndCity.isBlank() } + val mailError = remember(contact.mail) { !isMailValid(contact.mail) } if (showBackAlert) { CommonAlertDialog( header = stringResource(R.string.edit_contact_back_alert_header), - info = stringResource(R.string.edit_contact_back_alert_info), + info = stringResource(R.string.edit_contact_back_alert_information), onCancel = { showBackAlert = false }, onClickAction = { navController.popBackStack() }, - cancelText = stringResource(R.string.edit_contact_back_alert_cancel), + cancelText = stringResource(R.string.edit_contact_back_alert_change), actionText = stringResource(R.string.edit_contact_back_alert_action) ) } @@ -98,10 +108,10 @@ fun EditShippingContactScreen( Spacer(Modifier.weight(1f)) Button( onClick = { - viewModel.onSaveContact(contact!!) + viewModel.onSaveContact(contact) navController.popBackStack() }, - enabled = !telephoneError && !nameError && !line1Error && !codeAndCityError + enabled = !telephoneError && !mailError && !nameError && !line1Error && !codeAndCityError ) { Text(stringResource(R.string.edit_shipping_contact_save)) } @@ -111,17 +121,15 @@ fun EditShippingContactScreen( topBarTitle = stringResource(R.string.edit_shipping_contact_top_bar_title), listState = listState, onBack = { - if (contact != originalContact) { - showBackAlert = true - } else { + if (!telephoneError && !mailError && !nameError && !line1Error && !codeAndCityError) { + viewModel.onSaveContact(contact) navController.popBackStack() + } else { + showBackAlert = true } } ) { contentPadding -> - val imePadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.ime, - applyBottom = true, - ) + val imePadding = WindowInsets.ime.asPaddingValues() val focusManager = LocalFocusManager.current @@ -131,130 +139,199 @@ fun EditShippingContactScreen( verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium), contentPadding = PaddingValues( top = PaddingDefaults.Medium + contentPadding.calculateTopPadding(), - bottom = PaddingDefaults.Medium + max(imePadding.calculateBottomPadding(), contentPadding.calculateBottomPadding()), + bottom = PaddingDefaults.Medium + max( + imePadding.calculateBottomPadding(), + contentPadding.calculateBottomPadding() + ), start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, + end = PaddingDefaults.Medium ) ) { - contact?.let { c -> - item { - Text( - stringResource(R.string.edit_shipping_contact_title_contact), - style = MaterialTheme.typography.h6 - ) - } - item(key = "InputField_1") { - InputField( - modifier = Modifier.scrollOnFocus(1, listState).fillParentMaxWidth(), - value = c.telephoneNumber, - onValueChange = { phone -> contact = c.copy(telephoneNumber = phone) }, - onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, - label = { Text(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 = c.mail, - onValueChange = { mail -> contact = (c.copy(mail = mail)) }, - 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 = MaterialTheme.typography.h6 - ) - } - item(key = "InputField_3") { - InputField( - modifier = Modifier.scrollOnFocus(4, listState).fillParentMaxWidth(), - value = c.name, - onValueChange = { name -> contact = (c.copy(name = name)) }, - 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 = c.line1, - onValueChange = { line1 -> contact = (c.copy(line1 = line1)) }, - 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 = c.line2, - onValueChange = { line2 -> contact = (c.copy(line2 = line2)) }, - 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 = c.postalCodeAndCity, - onValueChange = { postalCodeAndCity -> - contact = (c.copy(postalCodeAndCity = postalCodeAndCity)) - }, - 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 = c.deliveryInformation, - onValueChange = { deliveryInformation -> - contact = (c.copy(deliveryInformation = deliveryInformation)) - }, - onSubmit = { focusManager.clearFocus() }, - label = { Text(stringResource(R.string.edit_shipping_contact_delivery_information)) }, - isError = false, - ) - } + 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) }, + onSubmit = { focusManager.moveFocus(FocusDirection.Down) }, + label = { Text(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)) }, + 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)) }, + 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)) }, + 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)) }, + 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)) + }, + 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)) + }, + onSubmit = { focusManager.clearFocus() }, + label = { Text(stringResource(R.string.edit_shipping_contact_delivery_information)) }, + isError = false + ) } } } } fun isMailValid(mail: String?): Boolean { - return if (mail.isNullOrBlank()) { - true - } else { - mail.contains('@') && mail.contains('.') - } + return mail.isNullOrEmpty() || Patterns.EMAIL_ADDRESS.matcher(mail).matches() } fun isPhoneValid(telephoneNumber: String?): Boolean { - return telephoneNumber != null && telephoneNumber.length >= 3 + return telephoneNumber != null && telephoneNumber.length >= 4 } -private fun Modifier.scrollOnFocus(to: Int, listState: LazyListState) = composed { +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) + } + } + } - onFocusEvent { + onFocusChanged { if (it.hasFocus) { + hasFocus = true coroutineScope.launch { - listState.animateScrollToItem(to) + 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 { + println("asdfadsfgsdgsdf 2") + + 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/PharmacyDetailsScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacyDetailsScreenComponents.kt index d2611e60..2f71cc90 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,13 +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.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,42 +55,38 @@ 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 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.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.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 @@ -100,7 +96,6 @@ import java.util.Locale @Composable fun PharmacyDetailsScreen( navController: NavController, - showRedeemOptions: Boolean, viewModel: PharmacySearchViewModel ) { val context = LocalContext.current @@ -108,38 +103,27 @@ fun PharmacyDetailsScreen( val state by viewModel.detailScreenState().collectAsState(null) val pharmacy = state?.selectedPharmacy - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = stringResource(R.string.pharmacy_detail_title), - onBack = { navController.popBackStack() } - ) - } + val scrollState = rememberScrollState() + + AnimatedElevationScaffold( + topBarTitle = stringResource(R.string.pharmacy_detail_title), + elevated = scrollState.value > 0, + actions = {}, + 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() ) { if (pharmacy != null) { - SpacerMedium() - if (pharmacy.ready) { - ReadyFlag() - SpacerSmall() - } - Text( text = pharmacy.name, - style = MaterialTheme.typography.h5 + style = AppTheme.typography.h5 ) SpacerSmall() Row( @@ -149,14 +133,14 @@ fun PharmacyDetailsScreen( launchMaps(context, it, pharmacy.name) } }, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically ) { Text( text = pharmacy.removeLineBreaksFromAddress(), - style = MaterialTheme.typography.subtitle2, - color = MaterialTheme.colors.secondary, + style = AppTheme.typography.subtitle2, + color = MaterialTheme.colors.secondary ) - Spacer8() + SpacerSmall() Icon( imageVector = Icons.Default.Map, contentDescription = "", @@ -164,31 +148,42 @@ fun PharmacyDetailsScreen( ) } - Spacer24() + SpacerLarge() if (pharmacy.ready) { - if (showRedeemOptions) { - OrderOptions(pharmacy) { - viewModel.onSelectOrderOption(it) - navController.navigate(PharmacyNavigationScreens.OrderPrescription.path()) - } - 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 - ) - }, - title = { Text(stringResource(R.string.pharm_detail_hint_header)) }, - body = { Text(stringResource(R.string.pharm_detail_hint)) } + val hasRedeemableTasks by produceState(false) { + viewModel.hasRedeemableTasks().collect { value = it } + } + + 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 ) } + SpacerMedium() + 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)) } + ) } else { HintCard( properties = HintCardDefaults.properties( @@ -219,14 +214,16 @@ fun PharmacyDetailsScreen( @Composable private fun OrderOptions( + hasRedeemableTasks: Boolean, pharmacy: PharmacyUseCaseData.Pharmacy, onClickOrder: (PharmacyScreenData.OrderOption) -> Unit ) { - if (pharmacy.roleCode.any { it == RoleCode.OUT_PHARM }) { + if (pharmacy.provides.any { it is PickUpPharmacyService }) { Button( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), + enabled = hasRedeemableTasks, onClick = { onClickOrder(PharmacyScreenData.OrderOption.ReserveInPharmacy) }, colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.secondary @@ -247,6 +244,7 @@ private fun OrderOptions( modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), + enabled = hasRedeemableTasks, onClick = { onClickOrder(PharmacyScreenData.OrderOption.CourierDelivery) }, colors = ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral050, @@ -262,12 +260,13 @@ private fun OrderOptions( ) } } - 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), + enabled = hasRedeemableTasks, onClick = { onClickOrder(PharmacyScreenData.OrderOption.MailDelivery) }, colors = ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral050, @@ -293,7 +292,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 @@ -317,7 +316,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() @@ -372,46 +371,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( @@ -509,13 +468,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 index 1fc54e2a..708e523d 100644 --- 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 @@ -34,9 +34,7 @@ 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.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card @@ -54,9 +52,9 @@ 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.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 @@ -73,14 +71,15 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.google.accompanist.insets.navigationBarsPadding +import androidx.compose.foundation.layout.navigationBarsPadding import de.gematik.ti.erp.app.R import de.gematik.ti.erp.app.cardwall.ui.PrimaryButton -import de.gematik.ti.erp.app.db.entities.ProfileColorNames +import de.gematik.ti.erp.app.cardwall.ui.SecondaryButton 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.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 @@ -92,33 +91,35 @@ 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.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Suppress("LongMethod") @Composable fun PharmacyOrderScreen( navController: NavController, - taskIds: List, viewModel: PharmacySearchViewModel, onSuccessfullyOrdered: (PharmacyScreenData.OrderOption) -> Unit ) { val listState = rememberLazyListState() val scaffoldState = rememberScaffoldState() - val state by viewModel.orderScreenState(taskIds).collectAsState(PharmacyScreenData.defaultOrderState) + val state by produceState(PharmacyScreenData.defaultOrderState) { + viewModel.orderScreenState().collect { + value = it + } + } val shippingContactCompleted = remember(state) { - state.prescriptions.isNotEmpty() && state.contact != null && !state.contact!!.phoneOrAddressMissing() + state.prescriptions.isNotEmpty() && !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 = if (viewModel.isDemoMode()) stringResource(R.string.pharmacy_order_button_text_reserve_demo_mode) - else stringResource(R.string.pharmacy_order_button_text_reserve) - + val reserveButtonText = stringResource(R.string.pharmacy_order_button_text_reserve) val orderButtonText = stringResource(R.string.pharmacy_order_button_text_order) val topBarTitle = remember(state) { @@ -155,8 +156,8 @@ fun PharmacyOrderScreen( Text( stringResource(R.string.pharmacy_order_bottom_information), textAlign = TextAlign.Center, - style = AppTheme.typography.captionl, - modifier = Modifier.fillMaxWidth(), + style = AppTheme.typography.caption1l, + modifier = Modifier.fillMaxWidth() ) SpacerSmall() PrimaryButton( @@ -172,7 +173,8 @@ fun PharmacyOrderScreen( withContext(Dispatchers.Main) { onSuccessfullyOrdered(state.orderOption) } - }, onFailure = { scaffoldState.snackbarHostState.showSnackbar(uploadErrorText) } + }, + onFailure = { scaffoldState.snackbarHostState.showSnackbar(uploadErrorText) } ) } finally { uploadInProgress = false @@ -201,7 +203,7 @@ fun PharmacyOrderScreen( top = PaddingDefaults.Medium + it.calculateTopPadding(), bottom = PaddingDefaults.Medium + it.calculateBottomPadding(), start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, + end = PaddingDefaults.Medium ) ) { item { @@ -211,13 +213,13 @@ fun PharmacyOrderScreen( item { Text( stringResource(R.string.pharmacy_order_contact_and_delivery_address), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) } item { if (state.prescriptions.isNotEmpty()) { Contact(state.activeProfile, state.contact, shippingContactCompleted, onClickEdit = { - if (!viewModel.isDemoMode()) navController.navigate(PharmacyNavigationScreens.EditShippingContact.path()) + navController.navigate(PharmacyNavigationScreens.EditShippingContact.path()) }) } else { Box(Modifier.fillMaxWidth().height(160.dp)) { @@ -227,20 +229,22 @@ fun PharmacyOrderScreen( SpacerLarge() } item { - Text(stringResource(R.string.pharmacy_order_title_prescriptions), style = MaterialTheme.typography.h6) + Text(stringResource(R.string.pharmacy_order_title_prescriptions), style = AppTheme.typography.h6) } - items(state.prescriptions) { (prescription, selected) -> - Prescription( - prescription = prescription, - selected = selected, - onSelect = { select -> - if (select) { - viewModel.onSelectOrder(prescription) - } else { - viewModel.onDeselectOrder(prescription) + state.prescriptions.forEach { (prescription, selected) -> + item { + Prescription( + prescription = prescription, + selected = selected, + onSelect = { select -> + if (select) { + viewModel.onSelectOrder(prescription) + } else { + viewModel.onDeselectOrder(prescription) + } } - } - ) + ) + } } } AnimatedVisibility( @@ -265,7 +269,7 @@ fun PharmacyOrderScreen( @Composable private fun Contact( activeProfile: ProfilesUseCaseData.Profile, - contact: PharmacyUseCaseData.ShippingContact?, + contact: PharmacyUseCaseData.ShippingContact, shippingContactCompleted: Boolean, onClickEdit: () -> Unit ) { @@ -275,21 +279,22 @@ private fun Contact( elevation = 0.dp, onClick = onClickEdit ) { - if (contact == null) { + if (contact.addressIsMissing()) { Column(Modifier.padding(PaddingDefaults.Medium)) { Row { val colors = profileColor(profileColorNames = activeProfile.color) - val ssoStatusColor = ssoStatusColor(activeProfile, activeProfile.ssoToken) + val ssoStatusColor = ssoStatusColor(activeProfile, activeProfile.ssoTokenScope) - Avatar(Modifier.size(40.dp), activeProfile.name, colors, ssoStatusColor) + Avatar(Modifier.size(40.dp), activeProfile, ssoStatusColor) SpacerMedium() Text( stringResource(R.string.pharmacy_order_contact_required), - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) } SpacerMedium() - PrimaryButton( + SecondaryButton( + modifier = Modifier.fillMaxWidth().padding(PaddingDefaults.Medium), onClick = { onClickEdit() } ) { Text(stringResource(R.string.pharmacy_order_edit_contact)) @@ -298,26 +303,28 @@ private fun Contact( } else { Row(Modifier.padding(PaddingDefaults.Medium)) { val colors = profileColor(profileColorNames = activeProfile.color) - val ssoStatusColor = ssoStatusColor(activeProfile, activeProfile.ssoToken) + val ssoStatusColor = ssoStatusColor(activeProfile, activeProfile.ssoTokenScope) - Avatar(Modifier.size(40.dp), activeProfile.name, colors, ssoStatusColor) + Avatar(Modifier.size(40.dp), activeProfile, ssoStatusColor) SpacerMedium() Column(Modifier.weight(1f)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column { if (contact.name.isNotBlank()) { - Text(contact.name, style = MaterialTheme.typography.subtitle1) + Text(contact.name, style = AppTheme.typography.subtitle1) } contact.address().forEach { - Text(it, style = MaterialTheme.typography.body1) + Text(it, style = AppTheme.typography.body1) } } if (contact.other().isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (contact.telephoneNumber.isNotBlank()) + if (contact.telephoneNumber.isNotBlank()) { SmallChip(Icons.Outlined.Phone, contact.telephoneNumber) - if (contact.mail.isNotBlank()) + } + if (contact.mail.isNotBlank()) { SmallChip(Icons.Outlined.Mail, contact.mail) + } } } if (contact.deliveryInformation.isNotBlank()) { @@ -330,7 +337,7 @@ private fun Contact( Text( stringResource(R.string.pharmacy_order_further_contact_information_required), color = AppTheme.colors.red900, - style = MaterialTheme.typography.subtitle2, + style = AppTheme.typography.subtitle2, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } @@ -357,7 +364,7 @@ private fun SmallChip( SpacerSmall() Text( text, - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) } } @@ -377,18 +384,14 @@ private fun ContactPreview() { AppTheme { Contact( activeProfile = ProfilesUseCaseData.Profile( - id = 0, + id = "0", name = "Irina Muster", - insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation( - insurantName = null, - insuranceIdentifier = null, - insuranceName = null - ), + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), active = false, - color = ProfileColorNames.SPRING_GRAY, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, lastAuthenticated = null, - ssoToken = null, - accessToken = null + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.Initials ), contact = PharmacyUseCaseData.ShippingContact( name = "Beate Muster", @@ -419,13 +422,6 @@ private fun Prescription( onClick = { onSelect(!selected) } ) { Row(Modifier.padding(PaddingDefaults.Medium)) { - Box( - Modifier.background(AppTheme.colors.neutral200, CircleShape).size(40.dp), - contentAlignment = Alignment.Center - ) { - Text("\uD83D\uDC8A") - } - SpacerMedium() Column( Modifier .weight(1f) @@ -434,7 +430,22 @@ private fun Prescription( else Modifier.align(Alignment.CenterVertically) ) ) { - Text(prescription.title, style = MaterialTheme.typography.subtitle1) + val prescriptionTitle = if (prescription.scannedOn != null) { + stringResource(R.string.order_scanned_prescription_header) + } else { + prescription.title + } + Text(prescriptionTitle, style = AppTheme.typography.subtitle1) + 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( @@ -462,7 +473,7 @@ private fun SelectedPrescriptionPreview() { taskId = "", title = "Ivermectin", substitutionsAllowed = false, - accessCode = "", + accessCode = "" ), selected = true, onSelect = {} @@ -479,7 +490,7 @@ private fun UnselectedPrescriptionPreview() { taskId = "", title = "Ivermectin", substitutionsAllowed = true, - accessCode = "", + accessCode = "" ), selected = false, onSelect = {} @@ -495,7 +506,7 @@ private fun DescriptionHeader(pharmacyName: String) { text = annotatedStringResource( id = R.string.pharm_reserve_subheader, buildAnnotatedString { - withStyle(MaterialTheme.typography.subtitle2.toSpanStyle()) { + withStyle(AppTheme.typography.subtitle2.toSpanStyle()) { append(pharmacyName) } } 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..b99f55b9 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchController.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.pharmacy.ui + +import android.annotation.SuppressLint +import android.content.Context +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.PharmacySearchUseCase +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.kodein.di.compose.rememberInstance +import java.time.OffsetDateTime + +private const val WaitForLocationUpdate = 5000L + +private val DefaultSearchData = PharmacyUseCaseData.SearchData( + name = "", + filter = PharmacyUseCaseData.Filter(), + locationMode = PharmacyUseCaseData.LocationMode.Disabled +) + +@Stable +class PharmacySearchController( + private val context: Context, + private val useCase: PharmacySearchUseCase, + coroutineScope: CoroutineScope +) { + private val searchChannel = Channel(capacity = 1) + + var isLoading by mutableStateOf(false) + private set + + var searchState by mutableStateOf(DefaultSearchData) + private set + + @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 = it + } + .flatMapLatest { searchData -> + isLoading = true + + 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) + } + }.cachedIn(coroutineScope) + } + .onEach { + isLoading = false + } + .flowOn(Dispatchers.IO) + .shareIn( + coroutineScope, + SharingStarted.Lazily, + 1 + ) + + enum class SearchQueryResult { + Send, NoLocationPermission, NoLocationFound + } + + suspend fun search( + name: String, + filter: PharmacyUseCaseData.Filter + ): SearchQueryResult = withContext(Dispatchers.IO) { + val hasLocationPermission = anyLocationPermissionGranted(context) + val locationMode = if (hasLocationPermission && filter.nearBy) { + queryLocation() + ?.let { PharmacyUseCaseData.LocationMode.Enabled(it) } + ?: PharmacyUseCaseData.LocationMode.Disabled + } else { + PharmacyUseCaseData.LocationMode.Disabled + } + + when { + !hasLocationPermission && filter.nearBy -> + SearchQueryResult.NoLocationPermission + locationMode == PharmacyUseCaseData.LocationMode.Disabled && filter.nearBy -> + SearchQueryResult.NoLocationFound + else -> { + isLoading = true + + searchChannel.send( + PharmacyUseCaseData.SearchData( + name = name, + filter = filter.copy(nearBy = locationMode is PharmacyUseCaseData.LocationMode.Enabled), + locationMode = locationMode + ) + ) + + SearchQueryResult.Send + } + } + } + + @SuppressLint("MissingPermission") + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun queryLocation(): 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(Location(longitude = it.longitude, latitude = it.latitude), null) + } + } + } else { + null + } + } +} + +@Composable +fun rememberPharmacySearchController(): PharmacySearchController { + val context = LocalContext.current + val pharmacySearchUseCase by rememberInstance() + val scope = rememberCoroutineScope() + return remember { + PharmacySearchController( + context = context, + useCase = pharmacySearchUseCase, + coroutineScope = scope + ) + } +} 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..2c313158 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreen.kt @@ -0,0 +1,812 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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.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.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.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.LocalFocusManager +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.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.fhir.model.LocalPharmacyService +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.Chip +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog +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 EnableLocationDialog( + onCancel: () -> Unit, + onAccept: () -> Unit +) { + CommonAlertDialog( + header = stringResource(R.string.search_pharmacies_location_na_header), + info = stringResource(R.string.search_enable_location_hint_info), + cancelText = stringResource(R.string.search_pharmacies_location_na_cancel), + actionText = stringResource(R.string.search_enable_location_hint_enable), + onCancel = onCancel, + onClickAction = onAccept + ) +} + +@Composable +private fun PharmacySearchInputfield( + 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 FilterBottomSheet( + modifier: Modifier, + extraContent: @Composable () -> Unit = {}, + filter: PharmacyUseCaseData.Filter, + onClickChip: (PharmacyUseCaseData.Filter) -> Unit, + onClickClose: () -> Unit +) { + 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 + ) { + Chip( + stringResource(R.string.search_pharmacies_filter_nearby), + closable = false, + checked = filter.nearBy + ) { + onClickChip( + filter.copy( + nearBy = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_open_now), + closable = false, + checked = filter.openNow + ) { + onClickChip( + filter.copy( + openNow = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_e_prescription_ready), + closable = false, + checked = filter.ready + ) { + onClickChip( + filter.copy( + ready = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_delivery_service), + closable = false, + checked = filter.deliveryService + ) { + onClickChip( + filter.copy( + nearBy = if (it) true else filter.nearBy, + deliveryService = it + ) + ) + } + Chip( + stringResource(R.string.search_pharmacies_filter_online_service), + closable = false, + checked = filter.onlineService + ) { + onClickChip( + filter.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, + onBack: () -> Unit +) { + var showEnableLocationDialog by remember { mutableStateOf(false) } + + 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 }) + ) + } + } + + if (showEnableLocationDialog) { + EnableLocationDialog( + onCancel = { + searchFilter = searchFilter.copy(nearBy = false) + showEnableLocationDialog = false + }, + onAccept = { + locationPermissionLauncher.launch(locationPermissions) + showEnableLocationDialog = 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 = { + FilterBottomSheet( + modifier = Modifier.navigationBarsPadding(), + filter = searchFilter, + onClickChip = { + focusManager.clearFocus() + scope.launch { + searchFilter = it + when (pharmacySearchController.search(name = searchName, filter = it)) { + PharmacySearchController.SearchQueryResult.Send -> {} + PharmacySearchController.SearchQueryResult.NoLocationPermission -> { + showEnableLocationDialog = true + } + PharmacySearchController.SearchQueryResult.NoLocationFound -> { + searchFilter = searchFilter.copy(nearBy = false) + } + } + } + }, + onClickClose = { scope.launch { modal.hide() } } + ) + }, + sheetState = modal + ) { + Column(Modifier.systemBarsPadding()) { + Column { + SpacerMedium() + PharmacySearchInputfield( + 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 -> { + showEnableLocationDialog = 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(), + 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, + 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 index 5454243c..3fa14928 100644 --- 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 @@ -18,1058 +18,515 @@ 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.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.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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding 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.LazyListState 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.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.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.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.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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector 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.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.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.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.SpacerMedium +import de.gematik.ti.erp.app.R +import de.gematik.ti.erp.app.cardwall.ui.PrimaryButtonSmall +import de.gematik.ti.erp.app.pharmacy.model.OftenUsedPharmacyData +import de.gematik.ti.erp.app.pharmacy.repository.model.OftenUsedPharmaciesViewModel +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.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.AnimatedElevationScaffold +import de.gematik.ti.erp.app.utils.compose.CommonAlertDialog 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 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 java.text.DecimalFormat -import java.time.OffsetDateTime +import org.kodein.di.compose.rememberViewModel + +private const val LastUsedPharmaciesListLength = 5 -@OptIn( - ExperimentalComposeUiApi::class, - ExperimentalMaterialApi::class, - FlowPreview::class -) +@OptIn(ExperimentalMaterialApi::class) @Composable -fun PharmacySearchScreen( - mainNavController: NavController, - onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, - viewModel: PharmacySearchViewModel = hiltViewModel() +fun PharmacyOverviewScreen( + onBack: () -> Unit, + onStartSearch: () -> Unit, + filter: PharmacyUseCaseData.Filter, + onFilterChange: (PharmacyUseCaseData.Filter) -> Unit, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit ) { - 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 listState = rememberLazyListState() val scope = rememberCoroutineScope() + val modal = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - 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( + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetContent = { FilterBottomSheet( modifier = Modifier.navigationBarsPadding(), - filter = state?.search?.filter ?: PharmacyUseCaseData.Filter(), - onClickChip = { search(filter = it) }, - onClickClose = { scope.launch { modal.hide() } } + 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 ) { - Scaffold( - scaffoldState = scaffoldState, - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = stringResource(R.string.search_pharmacy_title), - onBack = { mainNavController.popBackStack() } - ) - } + AnimatedElevationScaffold( + listState = listState, + topBarTitle = stringResource(R.string.redeem_header), + onBack = onBack ) { - 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 - ) { - onSelectPharmacy(item.pharmacy) - } - 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 pharmacyViewModel by rememberViewModel() + OverviewContent( + onSelectPharmacy = onSelectPharmacy, + listState = listState, + onFilterChange = onFilterChange, + searchFilter = filter, + onStartSearch = onStartSearch, + pharmacyViewModel = pharmacyViewModel, + onShowFilter = { + scope.launch { modal.show() } } + ) + } + } +} - 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() - ) +@Composable +private fun OverviewContent( + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + listState: LazyListState, + searchFilter: PharmacyUseCaseData.Filter, + onFilterChange: (PharmacyUseCaseData.Filter) -> Unit, + onStartSearch: () -> Unit, + pharmacyViewModel: OftenUsedPharmaciesViewModel, + onShowFilter: () -> Unit +) { + val oftenUsedPharmacyList by produceState(initialValue = listOf()) { + pharmacyViewModel.oftenUsedPharmaciesState().collect { value = it } + } - LinearProgressIndicator( - modifier = Modifier - .alpha(loadingAlpha) - .fillMaxWidth() + LazyColumn( + modifier = Modifier + .fillMaxSize(), + state = listState, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(vertical = PaddingDefaults.Medium) + ) { + item { + PharmacySearchButton( + modifier = Modifier.padding(horizontal = PaddingDefaults.Medium) + ) { + onFilterChange( + searchFilter.copy( + ready = true, + onlineService = false, + deliveryService = false, + openNow = false + ) ) + onStartSearch() } } + item { + FilterSection( + filter = searchFilter, + onClick = onFilterChange, + onClickFilter = onShowFilter, + onStartSearch = onStartSearch + ) + } + item { + OftenUsedPharmacies( + oftenUsedPharmacyList = oftenUsedPharmacyList, + onSelectPharmacy = onSelectPharmacy, + pharmacyViewModel = pharmacyViewModel + ) + } } } @Composable -private fun PharmacySearchErrorHint( - title: String, - subtitle: String, - action: String? = null, - onClickAction: (() -> Unit)? = null, - modifier: Modifier +private fun OftenUsedPharmacies( + oftenUsedPharmacyList: List, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + pharmacyViewModel: OftenUsedPharmaciesViewModel ) { - Box( - modifier = modifier - ) { + if (oftenUsedPharmacyList.isNotEmpty()) { Column( modifier = Modifier - .align(Alignment.Center) - .padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) ) { Text( - title, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center + stringResource(R.string.pharmacy_often_used_header), + style = AppTheme.typography.subtitle1, + modifier = Modifier.padding(top = PaddingDefaults.XXLarge, bottom = PaddingDefaults.Medium), + textAlign = TextAlign.Start ) - Text( - subtitle, - style = AppTheme.typography.body2l, - textAlign = TextAlign.Center - ) - if (action != null && onClickAction != null) { - TextButton(onClick = onClickAction) { - Text(action) + val shortOftenUsedPharmacyList = remember { oftenUsedPharmacyList.take(LastUsedPharmaciesListLength) } + Column { + for (oftenUsedPharmacy in shortOftenUsedPharmacyList) { + OftenUsedPharmacyCard( + oftenUsedPharmacy = oftenUsedPharmacy, + onSelectPharmacy = onSelectPharmacy, + pharmacyViewModel = pharmacyViewModel + ) + SpacerMedium() } } } } } -@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") +@Stable +private sealed interface RefreshState { + @Stable + object Loading : RefreshState - 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 - ) - ) + @Stable + class Success(val pharmacy: List) : RefreshState - 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) - ) - } -} + @Stable + object NotFound : RefreshState -@Preview -@Composable -fun PharmacyResultPlaceholderPreview() { - AppTheme { - PharmacyResultPlaceholder() - } + @Stable + object Error : RefreshState } -@OptIn(ExperimentalMaterialApi::class) @Composable -private fun SearchField( - searchValue: String, - onSearchChange: (String) -> Unit, - locationEnabled: Boolean, - onSearch: (String, Boolean) -> Unit, +private fun OftenUsedPharmacyCard( + oftenUsedPharmacy: OftenUsedPharmacyData.OftenUsedPharmacy, + onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, + pharmacyViewModel: OftenUsedPharmaciesViewModel ) { - 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 - ) + 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(oftenUsedPharmacy.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 } - textField() - } + ) + } + } - IconToggleButton( - checked = locationEnabled, - onCheckedChange = { - onSearch(searchValue, it) - }, - ) { - when (locationEnabled) { - true -> Icon( - Icons.Rounded.LocationSearching, - null, - tint = AppTheme.colors.primary600 - ) + val scope = rememberCoroutineScope() - false -> Icon( - Icons.Rounded.LocationDisabled, - null, - tint = AppTheme.colors.neutral600 + 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( + oftenUsedPharmacy.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 + } ) } } } - } + ) } -} - -@Preview -@Composable -fun SearchFieldPreview() { - AppTheme { - SearchField( - "", - {}, - 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.deleteOftenUsedPharmacy(oftenUsedPharmacy) } + showFailedPharmacyCallDialog = false + }, + acceptText = stringResource(R.string.pharmacy_search_apovz_call_failed_accept) ) } -} -@Composable -fun FilterSection( - filter: PharmacyUseCaseData.Filter, - onClickChip: (PharmacyUseCaseData.Filter) -> Unit, - onClickFilter: () -> Unit -) { - Column( + Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = PaddingDefaults.Medium), + .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 ) { - 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 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + PharmacyImagePlaceholder(modifier = Modifier.padding(PaddingDefaults.Medium)) + + Column( + modifier = Modifier.padding( + end = PaddingDefaults.Medium, + top = PaddingDefaults.Medium, + bottom = PaddingDefaults.Medium + ) ) { - 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)) - } - } + Text( + oftenUsedPharmacy.pharmacyName, + style = AppTheme.typography.subtitle1 + ) + Text( + oftenUsedPharmacy.address, + style = AppTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class) @Composable -private fun FilterBottomSheet( - modifier: Modifier, +private fun FilterSection( filter: PharmacyUseCaseData.Filter, - onClickChip: (PharmacyUseCaseData.Filter) -> Unit, - onClickClose: () -> Unit + onClick: (PharmacyUseCaseData.Filter) -> Unit, + onStartSearch: () -> Unit, + onClickFilter: () -> 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) + 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() } - 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 - ) - ) - } - } + ) + 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() } - } - SpacerXLarge() + ) + 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 PharmacyResultCard( +private fun FilterButton( modifier: Modifier, - pharmacy: PharmacyUseCaseData.Pharmacy, + text: String, + icon: ImageVector, onClick: () -> Unit ) { Row( modifier = Modifier - .clickable(onClick = onClick) - .then(modifier) + .fillMaxWidth() + .clickable(role = Role.Button) { onClick() } + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start ) { - 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) + 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 ) } } -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) +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 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)) - } - } +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/PharmacySearchScreenNavigationComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/ui/PharmacySearchScreenNavigationComponents.kt index 8358b7d9..3ff751d1 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,55 +18,150 @@ 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.hilt.navigation.compose.hiltViewModel +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.core.LocalActivity 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.tracking.TrackNavigationChanges +import de.gematik.ti.erp.app.analytics.TrackNavigationChanges +import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData import de.gematik.ti.erp.app.utils.compose.NavigationAnimation import de.gematik.ti.erp.app.utils.compose.navigationModeState +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel +private const val AnimationOffset = 9 + +@Suppress("LongMethod") +@OptIn(ExperimentalAnimationApi::class) @Composable -fun PharmacySearchScreenWithNavigation( - taskIds: List, +fun PharmacyNavigation( mainNavController: NavController, - viewModel: PharmacySearchViewModel = hiltViewModel(), - mainScreenVM: MainScreenViewModel = hiltViewModel(LocalActivity.current) + mainScreenVM: MainScreenViewModel ) { + val viewModel by rememberViewModel() + val scope = rememberCoroutineScope() + val pharmacySearchController = rememberPharmacySearchController() + var searchFilter by remember { mutableStateOf(PharmacyUseCaseData.Filter()) } + var showPharmacySearchResult by remember { mutableStateOf(false) } + + val locationPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + scope.launch { + pharmacySearchController.search( + name = "", + filter = searchFilter.copy(nearBy = permissions.values.any { it }) + ) + showPharmacySearchResult = true + } + } + + var showEnableLocationDialog by remember { mutableStateOf(false) } + if (showEnableLocationDialog) { + EnableLocationDialog( + onCancel = { + searchFilter = searchFilter.copy(nearBy = false) + showEnableLocationDialog = false + }, + onAccept = { + locationPermissionLauncher.launch(locationPermissions) + showEnableLocationDialog = false + } + ) + } + val navController = rememberNavController() val navigationMode by navController.navigationModeState(PharmacyNavigationScreens.SearchResults.route) + val startDestination = PharmacyNavigationScreens.StartSearch.route TrackNavigationChanges(navController) NavHost( navController, - startDestination = PharmacyNavigationScreens.SearchResults.route + startDestination = startDestination ) { - composable(PharmacyNavigationScreens.SearchResults.route) { + composable(PharmacyNavigationScreens.StartSearch.route) { NavigationAnimation(mode = navigationMode) { - PharmacySearchScreen( - mainNavController = mainNavController, - onSelectPharmacy = { - viewModel.onSelectPharmacy(it) - navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) - }, - viewModel, - ) + AnimatedContent( + targetState = showPharmacySearchResult, + transitionSpec = { + if (showPharmacySearchResult) { + slideInVertically(initialOffsetY = { it / AnimationOffset }) + fadeIn() with + fadeOut() + } else { + fadeIn(tween(durationMillis = 550)) with fadeOut(tween(durationMillis = 550)) + } + } + ) { + if (!it) { + PharmacyOverviewScreen( + onBack = { mainNavController.popBackStack() }, + onFilterChange = { searchFilter = it }, + filter = searchFilter, + onStartSearch = { + scope.launch { + when (pharmacySearchController.search(name = "", filter = searchFilter)) { + PharmacySearchController.SearchQueryResult.Send -> { + showPharmacySearchResult = true + } + PharmacySearchController.SearchQueryResult.NoLocationPermission -> { + showEnableLocationDialog = true + } + PharmacySearchController.SearchQueryResult.NoLocationFound -> { + pharmacySearchController.search( + name = "", + filter = searchFilter.copy(nearBy = false) + ) + showPharmacySearchResult = true + } + } + } + }, + onSelectPharmacy = { + viewModel.onSelectPharmacy(it) + navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) + } + ) + } else { + PharmacySearchResultScreen( + pharmacySearchController = pharmacySearchController, + onBack = { showPharmacySearchResult = false }, + onSelectPharmacy = { + viewModel.onSelectPharmacy(it) + navController.navigate(PharmacyNavigationScreens.PharmacyDetails.path()) + } + ) + + BackHandler { + showPharmacySearchResult = false + } + } + } } } composable(PharmacyNavigationScreens.PharmacyDetails.route) { NavigationAnimation(mode = navigationMode) { PharmacyDetailsScreen( navController, - showRedeemOptions = taskIds.isNotEmpty(), viewModel ) } @@ -75,7 +170,6 @@ fun PharmacySearchScreenWithNavigation( NavigationAnimation(mode = navigationMode) { PharmacyOrderScreen( navController, - taskIds, viewModel, onSuccessfullyOrdered = { mainScreenVM.onAction(ActionEvent.ReturnFromPharmacyOrder(it)) @@ -88,7 +182,6 @@ fun PharmacySearchScreenWithNavigation( NavigationAnimation(mode = navigationMode) { EditShippingContactScreen( navController, - taskIds, 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 f9ca79f0..a7c96b78 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,235 +19,67 @@ 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 android.os.Parcelable import androidx.core.content.ContextCompat -import androidx.lifecycle.SavedStateHandle 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.common.usecase.HintUseCase -import de.gematik.ti.erp.app.common.usecase.model.PharmacyScreenHintEnableLocation -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -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.OftenUsedPharmaciesUseCase 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.profiles.usecase.ProfilesUseCase -import java.time.OffsetDateTime -import javax.inject.Inject +import de.gematik.ti.erp.app.profiles.usecase.activeProfile import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -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.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -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.supervisorScope -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.parcelize.Parcelize - -private const val waitForLocationUpdate = 5000L +import java.util.UUID sealed class PharmacySearchUi { class Pharmacy(val pharmacy: PharmacyUseCaseData.Pharmacy) : PharmacySearchUi() - object LocationHint : PharmacySearchUi() } -private const val navStateKey = "pharmacyNavState" - -@HiltViewModel -class PharmacySearchViewModel @Inject constructor( - @ApplicationContext - private val context: Context, +class PharmacySearchViewModel( private val useCase: PharmacySearchUseCase, + private val oftenUseCase: OftenUsedPharmaciesUseCase, private val profilesUseCase: ProfilesUseCase, - private val hintUseCase: HintUseCase, - private val dispatcher: DispatchProvider, - private val savedStateHandle: SavedStateHandle, - private val demoUseCase: DemoUseCase, + 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 - ) - @SuppressLint("MissingPermission") @OptIn(ExperimentalCoroutinesApi::class) - private suspend fun queryLocation(): Location? = withTimeoutOrNull(waitForLocationUpdate) { - suspendCancellableCoroutine { continuation -> - val cancelTokenSource = CancellationTokenSource() - - continuation.invokeOnCancellation { cancelTokenSource.cancel() } - - 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 hasRedeemableTasks(): Flow = + profilesUseCase.profiles.map { it.activeProfile() }.flatMapLatest { + useCase.hasRedeemableTasks(it.id) } - } - @Parcelize private data class NavState( // orders identified by their taskId val unSelectedPrescriptions: Set, val selectedOrderOption: PharmacyScreenData.OrderOption?, val selectedPharmacy: PharmacyUseCaseData.Pharmacy? - ) : Parcelable + ) private val navState = MutableStateFlow( - savedStateHandle.get(navStateKey) ?: NavState( + NavState( unSelectedPrescriptions = setOf(), selectedOrderOption = null, selectedPharmacy = null ) ) - 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 - ) - } - viewModelScope.launch { - navState.collect { - savedStateHandle.set(navStateKey, it) - } - } - } - - /** - * 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 - } - searchChannel.send(PharmacyUseCaseData.SearchData(name, filter, locationMode)) - - withLocationEnabled && locationMode is PharmacyUseCaseData.LocationMode.Disabled - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun screenState(): Flow = flow { - emitAll( - combine(hintUseCase.cancelledHints, searchState.filterNotNull()) { cancelledHints, search -> - PharmacyUseCaseData.State( - search = search, - PharmacyScreenHintEnableLocation !in cancelledHints - ) - } - ) - } - - fun cancelLocationHint() { - hintUseCase.cancelHint(PharmacyScreenHintEnableLocation) - } - fun detailScreenState() = navState.transform { if (it.selectedPharmacy != null) { emit( @@ -264,44 +96,24 @@ class PharmacySearchViewModel @Inject constructor( } } - fun orderScreenState(taskIds: List): Flow = - combine( - profilesUseCase.profiles.map { - it.find { profile -> - profile.active - }!! - }, - if (demoUseCase.isDemoModeActive) getDemoOrders(taskIds) else useCase.prescriptionDetailsForOrdering(taskIds), - navState.filter { it.selectedPharmacy != null && it.selectedOrderOption != null } - ) { activeProfile, 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!! - ) - } - - private fun getDemoOrders(taskIds: List): Flow { - return flow { - taskIds.map { taskIdToOrder -> - demoUseCase.demoTasks.value.find { demoTasks -> demoTasks.taskId == taskIdToOrder }!! - .let { - PharmacyUseCaseData.PrescriptionOrder( - it.taskId, - it.accessCode ?: "", - it.medicationText ?: "", - true - ) - } - }.apply { - emit(PharmacyUseCaseData.OrderState(this, demoUseCase.demoContact)) + @OptIn(ExperimentalCoroutinesApi::class) + 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 onSelectOrderOption(option: PharmacyScreenData.OrderOption) { navState.update { @@ -322,42 +134,28 @@ class PharmacySearchViewModel @Inject constructor( } fun onSaveContact(contact: PharmacyUseCaseData.ShippingContact) { - viewModelScope.launch(dispatcher.default()) { + viewModelScope.launch { useCase.saveShippingContact(contact) } } - suspend fun triggerOrderInPharmacy(state: PharmacyScreenData.OrderScreenState): Result { - return if (demoUseCase.isDemoModeActive) - orderDemoRecipe(state) - else - orderRecipe(state) - } - - private fun orderDemoRecipe(state: PharmacyScreenData.OrderScreenState): Result { - demoUseCase.demoTasks.update { demoTasks -> - demoTasks.map { task -> - if (state.prescriptions.any { it.first.taskId == task.taskId }) - task.copy(redeemedOn = OffsetDateTime.now()) - else - task - } - } - return Result.success(Unit) - } + suspend fun triggerOrderInPharmacy(state: PharmacyScreenData.OrderScreenState): Result = + orderRecipe(state) private suspend fun orderRecipe(state: PharmacyScreenData.OrderScreenState): Result { return supervisorScope { - withContext(dispatcher.io()) { + withContext(dispatchers.IO) { val redeemOption = state.orderOption val telematikId = state.selectedPharmacy.telematikId val contact = requireNotNull(state.contact) + val orderId = UUID.randomUUID() val result = state.prescriptions.filter { it.second } .map { (prescription, _) -> async { useCase.redeemPrescription( - profileName = state.activeProfile.name, + orderId = orderId, + profileId = state.activeProfile.id, redeemOption = when (redeemOption) { PharmacyScreenData.OrderOption.ReserveInPharmacy -> RemoteRedeemOption.Local PharmacyScreenData.OrderOption.CourierDelivery -> RemoteRedeemOption.Delivery @@ -371,13 +169,11 @@ class PharmacySearchViewModel @Inject constructor( } .awaitAll() .find { it.isFailure } - + oftenUseCase.saveOrUpdateUsedPharmacies(state.selectedPharmacy) result?.let { Result.failure(it.exceptionOrNull()!!) } ?: Result.success(Unit) } } } - - fun isDemoMode() = demoUseCase.isDemoModeActive } val locationPermissions = arrayOf( 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 index 7be12130..e9eec362 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -35,21 +36,31 @@ 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, - source: Int + aspectRatioOverwrite: Float? = null, + @RawRes source: Int ) { val context = LocalContext.current - var aspectRatio by remember { mutableStateOf(1f) } + 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 -> - aspectRatio = width / max(1f, height.toFloat()) + if (aspectRatioOverwrite == null) { + aspectRatio = width / max(1f, height.toFloat()) + } } isLooping = true @@ -83,7 +94,14 @@ fun VideoContent( view }, modifier = modifier - .aspectRatio(aspectRatio) + .then( + // prevent irritating large surfaces on first layout calc + if (aspectRatio == 0f) { + Modifier + } else { + Modifier.aspectRatio(aspectRatio) + } + ) .onSizeChanged { size = it } 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 d11f78f1..65a50580 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 @@ -23,7 +23,8 @@ 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 PharmacyDetails : Route("PharmacyDetails") 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 index abd48d9e..f3d53cf4 100644 --- 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 @@ -20,11 +20,11 @@ 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.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyContacts +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 { @@ -36,7 +36,7 @@ object PharmacyScreenData { @Immutable data class OrderScreenState( val activeProfile: ProfilesUseCaseData.Profile, - val contact: ShippingContact?, + val contact: ShippingContact, val prescriptions: List>, val selectedPharmacy: PharmacyUseCaseData.Pharmacy, val orderOption: OrderOption @@ -54,15 +54,24 @@ object PharmacyScreenData { val defaultOrderState = OrderScreenState( activeProfile = ProfilesUseCaseData.Profile( - id = 0, name = "", - insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation( - insurantName = null, - insuranceIdentifier = null, - insuranceName = null - ), - active = false, color = ProfileColorNames.SPRING_GRAY, lastAuthenticated = null, ssoToken = null, accessToken = null + id = "0", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + lastAuthenticated = null, + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.Initials + ), + contact = ShippingContact( + name = "", + line1 = "", + line2 = "", + postalCodeAndCity = "", + telephoneNumber = "", + mail = "", + deliveryInformation = "" ), - contact = null, prescriptions = listOf(), selectedPharmacy = PharmacyUseCaseData.Pharmacy( name = "", @@ -73,7 +82,6 @@ object PharmacyScreenData { provides = listOf(), openingHours = null, telematikId = "", - roleCode = listOf(), ready = false ), orderOption = OrderOption.ReserveInPharmacy diff --git a/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/OftenUsedPharmaciesUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/OftenUsedPharmaciesUseCase.kt new file mode 100644 index 00000000..e78de56a --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/OftenUsedPharmaciesUseCase.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.fhir.model.Pharmacy +import de.gematik.ti.erp.app.pharmacy.model.OftenUsedPharmacyData +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.flow.map +import kotlinx.coroutines.withContext + +class OftenUsedPharmaciesUseCase( + private val repository: PharmacyRepository, + private val dispatchers: DispatchProvider +) { + fun oftenUsedPharmacies(): Flow> { + return repository.loadOftenUsedPharmacies().flowOn(dispatchers.IO) + } + + suspend fun saveOrUpdateUsedPharmacies(pharmacy: PharmacyUseCaseData.Pharmacy) { + repository.saveOrUpdateOftenUsedPharmacy(pharmacy) + } + + suspend fun deleteOftenUsedPharmacy(oftenUsedPharmacy: OftenUsedPharmacyData.OftenUsedPharmacy) = + repository.deleteOftenUsedPharmacy(oftenUsedPharmacy) + + suspend fun searchPharmacyByTelematikId( + telematikId: String + ): Result> = withContext(dispatchers.IO) { + repository.searchPharmacyByTelematikId(telematikId) + .map { mapPharmacies(it.pharmacies) } + } +} 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/PharmacySearchUseCase.kt b/android/src/main/java/de/gematik/ti/erp/app/pharmacy/usecase/PharmacySearchUseCase.kt index 83047616..6cb67db3 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,48 +23,49 @@ 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.db.entities.ShippingContactEntity +import de.gematik.ti.erp.app.fhir.model.LocalPharmacyService +import de.gematik.ti.erp.app.fhir.model.Pharmacy +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.ShippingContactRepository 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.usecase.model.PharmacyUseCaseData -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.extractShippingContact +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 javax.inject.Inject import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext -import okhttp3.ResponseBody +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json 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 java.util.UUID -private const val initialResultsPerPage = 80 +// can't be modified; the backend will always return 80 entries on the first page +private const val InitialResultsPerPage = 80 +private const val NextResultsPerPage = 10 -class PharmacySearchUseCase @Inject constructor( +private val json = Json { encodeDefaults = true } + +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 dispatchers: DispatchProvider ) { data class PharmacyPagingKey(val bundleId: String, val offset: Int) @@ -99,7 +100,7 @@ class PharmacySearchUseCase @Inject constructor( .map { LoadResult.Page( data = mapPharmacies(it.pharmacies), - nextKey = if (it.bundleResultCount == initialResultsPerPage) { + nextKey = if (it.bundleResultCount == InitialResultsPerPage) { PharmacyPagingKey( it.bundleId, it.bundleResultCount @@ -130,7 +131,7 @@ class PharmacySearchUseCase @Inject constructor( 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 ) }.getOrElse { LoadResult.Error(it) } } @@ -138,147 +139,145 @@ class PharmacySearchUseCase @Inject constructor( } } - 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 = NextResultsPerPage, + initialLoadSize = InitialResultsPerPage, + maxSize = InitialResultsPerPage * 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( - taskIds: List - ): Flow { - return combine( + profileId: ProfileIdentifier + ): Flow = + combine( shippingContactRepository.shippingContact(), - prescriptionRepository.loadTasksForTaskId(*taskIds.toTypedArray()).map { tasks -> + prescriptionRepository.syncedTasks(profileId).map { tasks -> + tasks.filter { + it.redeemState().isRedeemable() + } + }, + prescriptionRepository.scannedTasks(profileId).map { tasks -> tasks.filter { - // filter prescriptions already assigned to a pharmacy - it.accessCode != null + it.isRedeemable() } } - ) { shippingContacts, tasks -> - if (tasks.isNotEmpty()) { - val shippingContact = ( - shippingContacts.firstOrNull() ?: run { - val bundle = mapper.parseKBVBundle(requireNotNull(tasks.first().rawKBVBundle)) - // initialize shipping contact with patient information from bundle - requireNotNull(bundle.extractShippingContact()).also { - shippingContactRepository.insertShippingContact(it) - } + + ) { shippingContacts, syncedTasks, scannedTasks -> + + val shippingContact = if (syncedTasks.isNotEmpty()) { + shippingContacts ?: run { + syncedTasks.first().shippingContact().also { + shippingContactRepository.saveShippingContact(it) } - ).let { - PharmacyUseCaseData.ShippingContact( - name = it.name, - line1 = it.line1, - line2 = it.line2, - postalCodeAndCity = it.postalCodeAndCity, - telephoneNumber = it.telephoneNumber, - mail = it.mail, - deliveryInformation = it.deliveryInformation - ) } + } else { + shippingContacts + } - PharmacyUseCaseData.OrderState( - tasks.map { task -> - val bundle = mapper.parseKBVBundle(requireNotNull(task.rawKBVBundle)) - mapToUIPrescriptionOrder( - task, - requireNotNull(bundle.extractMedication()), - requireNotNull(bundle.extractMedicationRequest()) - ) - }, - shippingContact + val tasks = scannedTasks.map { task -> + PharmacyUseCaseData.PrescriptionOrder( + taskId = task.taskId, + accessCode = task.accessCode, + title = "", + scannedOn = task.scannedOn, + substitutionsAllowed = false ) - } else { - PharmacyUseCaseData.OrderState( - emptyList(), null + } + 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.insertShippingContact( + shippingContactRepository.saveShippingContact( mapShippingContact(contact) ) } suspend fun redeemPrescription( - profileName: String, + profileId: ProfileIdentifier, redeemOption: RemoteRedeemOption, + orderId: UUID, order: PharmacyUseCaseData.PrescriptionOrder, contact: PharmacyUseCaseData.ShippingContact, pharmacyTelematikId: String - ): Result { - val payload = generatePayLoad( - redeemOption, - contact.name, - listOf(contact.line1, contact.line2, contact.postalCodeAndCity), - phone = contact.telephoneNumber + ): Result { + val accessCode = if (order.scannedOn != null) { + order.accessCode + } else { + null + } + + val payload = generatePayload( + redeemOption = redeemOption, + patientName = contact.name, + address = listOf(contact.line1, contact.line2, contact.postalCodeAndCity), + phone = contact.telephoneNumber, + hint = contact.deliveryInformation ) - val reference = assembleReference(order.taskId, order.accessCode) - val communication = generateFhirObject(reference, pharmacyTelematikId, payload) - return prescriptionRepository.redeemPrescription(profileName, communication) + val communication = generateFhirObject( + orderId = orderId.toString(), + reference = assembleTaskReference(order.taskId, order.accessCode), + telematicsId = pharmacyTelematikId, + payload = payload + ) + return prescriptionRepository.redeemPrescription(profileId, communication, accessCode = accessCode) } private fun mapShippingContact(contact: PharmacyUseCaseData.ShippingContact) = - ShippingContactEntity( - id = 0, + PharmacyData.ShippingContact( name = contact.name.trim(), line1 = contact.line1.trim(), line2 = contact.line2.trim(), @@ -288,24 +287,37 @@ class PharmacySearchUseCase @Inject constructor( deliveryInformation = contact.deliveryInformation.trim() ) - private fun generatePayLoad( + private fun generatePayload( redeemOption: RemoteRedeemOption, patientName: String, address: List, - phone: String + phone: String, + hint: String ): String { val com = CommunicationPayload( version = "1", supplyOptionsType = redeemOption.type, name = patientName, address = address, - phone = phone + phone = phone, + hint = hint ) - return comAdapter.toJson(com) + return json.encodeToString(com) } - private fun generateFhirObject(reference: String, telematicsId: String, payload: String) = + private fun generateFhirObject( + orderId: String, + reference: String, + telematicsId: String, + payload: String + ) = Communication().apply { + val orderIdentifier = Identifier().apply { + system = "https://gematik.de/fhir/NamingSystem/OrderID" + value = orderId + } + identifier = listOf(orderIdentifier) + meta = Meta().addProfile(PROFILE) addBasedOn(Reference(reference)) addPayload( @@ -324,7 +336,24 @@ class PharmacySearchUseCase @Inject constructor( return identifier } - private fun assembleReference(taskId: String, accessCode: String): String { + private fun assembleTaskReference(taskId: String, accessCode: String): String { return "Task/$taskId\$accept?ac=$accessCode" } } + +fun mapPharmacies(pharmacies: List): List = + 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.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 7b8d4b04..8e80c977 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 @@ -21,30 +21,30 @@ package de.gematik.ti.erp.app.pharmacy.usecase.model import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -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 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 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, @@ -55,9 +55,8 @@ object PharmacyUseCaseData { val provides: List, val openingHours: OpeningHours?, val telematikId: String, - val roleCode: List, val ready: Boolean - ) : Parcelable { + ) { @Stable fun removeLineBreaksFromAddress(): String { @@ -70,15 +69,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 + class Enabled(val location: Location) : LocationMode() } @Immutable @@ -89,8 +87,7 @@ object PharmacyUseCaseData { */ @Immutable data class State( - val search: SearchData, - val showLocationHint: Boolean + val search: SearchData ) @Immutable @@ -98,11 +95,11 @@ object PharmacyUseCaseData { val taskId: String, val accessCode: String, val title: String, + val scannedOn: Instant? = null, val substitutionsAllowed: Boolean ) @Immutable - @Parcelize data class ShippingContact( val name: String, val line1: String, @@ -111,7 +108,7 @@ object PharmacyUseCaseData { val telephoneNumber: String, val mail: String, val deliveryInformation: String - ) : Parcelable { + ) { @Stable fun toList() = listOf( name, @@ -138,12 +135,15 @@ object PharmacyUseCaseData { ).filter { it.isNotBlank() } @Stable - fun phoneOrAddressMissing() = telephoneNumber.isBlank() || name.isBlank() || line1.isBlank() || postalCodeAndCity.isBlank() + fun phoneOrAddressMissing() = telephoneNumber.isBlank() || addressIsMissing() + + @Stable + fun addressIsMissing() = name.isBlank() || line1.isBlank() || postalCodeAndCity.isBlank() } @Immutable data class OrderState( val prescriptions: List, - val contact: ShippingContact? + 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..9f182da3 --- /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()) } +} 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..1be456bc --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/DeleteSnackbar.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.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) + else -> null + } 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 5f33a9f7..4055a0c9 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 @@ -20,7 +20,6 @@ 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 @@ -29,19 +28,23 @@ 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.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.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.aspectRatio 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.padding 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.RoundedCornerShape @@ -49,29 +52,29 @@ 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.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.SnackbarHost 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.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.res.stringResource @@ -79,164 +82,120 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight 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.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.rememberInsetsPaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.api.ApiCallException -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.prescription.detail.ui.model.PrescriptionDetailsNavigationScreens +import de.gematik.ti.erp.app.core.LocalAuthenticator 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.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.prescription.repository.statusMapping +import de.gematik.ti.erp.app.prescription.ui.CompletedStatusChip +import de.gematik.ti.erp.app.prescription.ui.InProgressStatusChip +import de.gematik.ti.erp.app.prescription.ui.PendingStatusChip +import de.gematik.ti.erp.app.prescription.ui.PrescriptionServiceErrorState +import de.gematik.ti.erp.app.prescription.ui.ReadyStatusChip +import de.gematik.ti.erp.app.prescription.ui.UnknownStatusChip import de.gematik.ti.erp.app.prescription.ui.expiryOrAcceptString -import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier 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.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.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 de.gematik.ti.erp.app.utils.compose.annotatedPluralsResource +import de.gematik.ti.erp.app.utils.dateTimeShortText +import kotlinx.coroutines.launch +import org.kodein.di.compose.rememberViewModel +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.Locale private const val MISSING_VALUE = "---" -private const val FORBIDDEN = 403 @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() } - ) - } - } + PrescriptionDetailsWithScaffold( + viewModel = viewModel, + taskId = taskId, + onCancel = { mainNavController.popBackStack() } + ) } -@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) - } - - val lowDetailRedeemEvents by produceState>(mutableListOf()) { - viewModel.loadLowDetailEvents(taskId).collect { + viewModel.screenState(taskId).collect { value = it } } - val header = stringResource(id = R.string.prescription_details) - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = header, - onBack = onCancel - ) - }, + val scaffoldState = rememberScaffoldState() + val listState = rememberLazyListState() + + AnimatedElevationScaffold( + scaffoldState = scaffoldState, + listState = listState, + onBack = onCancel, + topBarTitle = stringResource(R.string.prescription_details), + navigationMode = NavigationBarMode.Close, + snackbarHost = { SnackbarHost(it, modifier = Modifier.navigationBarsPadding()) } ) { innerPadding -> - Box( - Modifier - .padding(innerPadding) - .fillMaxSize() - ) { - state?.let { - NavigationAnimation(mode = navigationMode) { - PrescriptionDetails( - viewModel = viewModel, - mainNavController = mainNavController, - state = it, - lowDetailRedeemEvents = lowDetailRedeemEvents, - onCancel = onCancel - ) - } - } + state?.let { + PrescriptionDetails( + viewModel = viewModel, + listState = listState, + scaffoldState = scaffoldState, + profileId = it.profileId, + state = it, + onCancel = onCancel + ) } } } -@OptIn(ExperimentalAnimationApi::class) @Composable private fun PrescriptionDetails( modifier: Modifier = Modifier, - mainNavController: NavController, + profileId: ProfileIdentifier, + listState: LazyListState, + scaffoldState: ScaffoldState, viewModel: PrescriptionDetailsViewModel, state: UIPrescriptionDetail, - lowDetailRedeemEvents: List, onCancel: () -> Unit ) { var showMore by remember { mutableStateOf(false) } - val listState = rememberLazyListState() - - 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 LazyColumn( state = listState, modifier = modifier .fillMaxSize(), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ) + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() ) { if ((state as? UIPrescriptionDetailSynced)?.medicationRequest?.emergencyFee == true && state.redeemedOn == null) { item { @@ -244,116 +203,18 @@ private fun PrescriptionDetails( } } - // 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()) - } - } - ) - } - } - - // 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() - } - ) - } - } - - state.bitmapMatrix?.let { bitMatrix -> - if (state.redeemedOn == null) { - item { - DataMatrixCode(bitMatrix) - } - } - } - - 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 - } - 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) - ) - ) - ) - } - } - 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) { + if (state.matrixPayload != null && state.redeemedOn == null) { item { - SubstitutionAllowed() + DataMatrixCode(state.matrixPayload!!) } } item { - Column { - if (state is UIPrescriptionDetailScanned) { - ProtocolScanned(state, lowDetailRedeemEvents) - } - if (state is UIPrescriptionDetailSynced) { - MedicationInformation(state, isSubstituted) - if (isSubstituted) { - WasSubstitutedHint() - } - DosageInformation(state, isSubstituted) - HealthPortalLink() - PatientInformation(state.patient, state.insurance) + when (state) { + is UIPrescriptionDetailScanned -> MedicationDetailScanned(state) { redeem -> + viewModel.redeemScannedTask(state.taskId, redeem) } + is UIPrescriptionDetailSynced -> MedicationDetailSynced(state) } } @@ -366,7 +227,7 @@ private fun PrescriptionDetails( .toggleable( value = showMore, onValueChange = { showMore = it }, - role = Role.Checkbox, + role = Role.Checkbox ), colors = ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral050, @@ -396,7 +257,7 @@ private fun PrescriptionDetails( targetState = showMore }, enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), + exit = fadeOut() + shrinkVertically() ) { Column { if (state is UIPrescriptionDetailSynced) { @@ -410,29 +271,30 @@ private fun PrescriptionDetails( ) val context = LocalContext.current - val accessInfoText = stringResource(R.string.logout_delete_no_access) - val prescriptionInProgressText = - stringResource(R.string.logout_delete_in_progress) + val authenticator = LocalAuthenticator.current + val deletePrescriptionsHandle = remember { + DeletePrescriptions( + bridge = viewModel, + authenticator = authenticator + ) + } + val coroutineScope = rememberCoroutineScope() DeleteButton(state is UIPrescriptionDetailSynced) { - viewModel.deletePrescription( - state.taskId, - state is UIPrescriptionDetailSynced - ).apply { - fold( - onSuccess = { onCancel() }, - onFailure = { - if (it is ApiCallException) { - if (it.response.code() == FORBIDDEN) { - createToastShort(context, prescriptionInProgressText) - } else { - createToastShort(context, accessInfoText) - } - } else { - createToastShort(context, accessInfoText) + val deleteState = deletePrescriptionsHandle.deletePrescription( + profileId = profileId, + taskId = state.taskId + ) + + when (deleteState) { + is PrescriptionServiceErrorState -> { + coroutineScope.launch { + deleteErrorMessage(context, deleteState)?.let { + scaffoldState.snackbarHostState.showSnackbar(it) } } - ) + } + is DeletePrescriptions.State.Deleted -> onCancel() } } } @@ -440,28 +302,115 @@ private fun PrescriptionDetails( } } } +} + +@Composable +fun MedicationDetailSynced(prescription: UIPrescriptionDetailSynced) { + if (prescription.medicationRequest.medication is SyncedTaskData.MedicationPZN || + prescription.medicationRequest.medication is SyncedTaskData.MedicationFreeText + ) { + if (prescription.medicationRequest.substitutionAllowed && prescription.redeemedOn == null) { + SubstitutionAllowed() + } + } + + PrescriptionStatusChip(prescription.state) + + if (prescription.medicationDispenses.isNotEmpty()) { + Header( + annotatedPluralsResource( + R.plurals.medication_detail_dispensed_medications_header, + prescription.medicationDispenses.size + ).text + ) + + if (prescription.medicationDispenses.first().wasSubstituted) { + WasSubstitutedHint() + } + + prescription.medicationDispenses.forEach { + it.medication?.let { medication -> + MedicationInformation(medication = medication) + } + } + + DosageInformation(prescription, prescription.medicationDispenses.first().wasSubstituted) + } - val scrollOffset = with(LocalDensity.current) { - 200.dp.toPx() + Header(stringResource(R.string.prescription_detail_requested_medication)) + FullDetailSecondHeader(prescription) + prescription.medicationRequest.medication?.let { medication -> + MedicationInformation(medication) } - LaunchedEffect(showMore) { - if (showMore) { - delay(100) - listState.animateScrollBy(scrollOffset) + DosageInformation(prescription, false) + + Column { + HealthPortalLink() + } + + PatientInformation(prescription.patient, prescription.insurance) +} + +@Composable +private fun PrescriptionStatusChip( + state: SyncedTaskData.SyncedTask.TaskState +) { + Column( + modifier = Modifier.padding( + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium, + top = PaddingDefaults.XXLarge + ) + ) { + when (state) { + is SyncedTaskData.SyncedTask.Other -> { + when (state.state) { + SyncedTaskData.TaskStatus.InProgress -> InProgressStatusChip() + SyncedTaskData.TaskStatus.Completed -> CompletedStatusChip() + else -> UnknownStatusChip() + } + } + is SyncedTaskData.SyncedTask.Pending -> PendingStatusChip() + is SyncedTaskData.SyncedTask.Ready -> ReadyStatusChip() + else -> {} } } } @Composable -private fun DataMatrixCode(bitmapMatrix: BitMatrixCode) { +fun MedicationInformation(medication: SyncedTaskData.Medication) { + when (medication) { + is SyncedTaskData.MedicationPZN -> PZNMedicationInformation(medication) + is SyncedTaskData.MedicationIngredient -> IngredientMedicationInformation(medication) + is SyncedTaskData.MedicationCompounding -> CompoundingMedicationInformation(medication) + is SyncedTaskData.MedicationFreeText -> FreeTextMedicationInformation(medication) + } + Divider() +} + +@Composable +fun MedicationDetailScanned(state: UIPrescriptionDetailScanned, onSwitchRedeem: (Boolean) -> Unit) { + Header( + text = stringResource( + id = R.string.scanned_prescription_placeholder_name, + state.number + ) + ) + LowDetailRedeemHeader(state) { + onSwitchRedeem(it) + } +} + +@Composable +private fun DataMatrixCode(payload: String) { Surface( shape = RoundedCornerShape(PaddingDefaults.Medium / 2), border = BorderStroke(1.dp, AppTheme.colors.neutral300), modifier = Modifier.padding(16.dp) ) { DataMatrixCode( - bitmapMatrix, + payload = payload, modifier = Modifier .aspectRatio(1.0f) ) @@ -469,8 +418,12 @@ private fun DataMatrixCode(bitmapMatrix: BitMatrixCode) { } @Composable -private fun DeleteButton(isSyncedPrescription: Boolean, onClickDelete: () -> Unit) { +private fun DeleteButton(isSyncedPrescription: Boolean, onClickDelete: suspend () -> Unit) { var showDeletePrescriptionDialog by remember { mutableStateOf(false) } + var deletionInPogress by remember { mutableStateOf(false) } + + val coroutineScope = rememberCoroutineScope() + val mutex = MutatorMutex() val deleteText = when (isSyncedPrescription) { true -> stringResource(R.string.pres_detail_delete) @@ -513,12 +466,22 @@ private fun DeleteButton(isSyncedPrescription: Boolean, onClickDelete: () -> Uni info = info, cancelText = cancelText, actionText = actionText, + enabled = !deletionInPogress, onCancel = { showDeletePrescriptionDialog = false }, onClickAction = { - onClickDelete() - showDeletePrescriptionDialog = false + coroutineScope.launch { + mutex.mutate { + try { + deletionInPogress = true + onClickDelete() + } finally { + showDeletePrescriptionDialog = false + deletionInPogress = false + } + } + } } ) } @@ -526,83 +489,34 @@ private fun DeleteButton(isSyncedPrescription: Boolean, onClickDelete: () -> Uni @Composable private fun FullDetailSecondHeader( - prescriptionDetail: UIPrescriptionDetailSynced, - onClickRedeem: () -> Unit + prescriptionDetail: UIPrescriptionDetailSynced ) { - 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, - ) - } else { - prescriptionDetail.redeemUntil?.let { expiryDate -> - prescriptionDetail.acceptUntil?.let { acceptDate -> - expiryOrAcceptString( - expiryDate = expiryDate, - acceptDate = acceptDate, - nowInEpochDays = LocalDate.now().toEpochDay() - ) - } + if (prescriptionDetail.medicationDispenses.isNotEmpty()) { + val timestamp = remember(LocalConfiguration.current, prescriptionDetail) { + val dtFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + LocalDateTime.ofInstant( + prescriptionDetail.medicationDispenses.first().whenHandedOver, + ZoneId.systemDefault() + ).format(dtFormatter) } - } ?: "" + stringResource(R.string.pres_detail_medication_redeemed_on, timestamp) + } else if (prescriptionDetail.taskStatus == SyncedTaskData.TaskStatus.InProgress) { + stringResource(R.string.pres_detail_medication_in_progress) + } else { + expiryOrAcceptString(prescriptionDetail.state) + } Text( text = text, style = AppTheme.typography.body2l, - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium - ) + modifier = Modifier.padding(horizontal = 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 - } - } - - if (prescriptionDetail.redeemedOn == null && redeemable) { - Button( - onClick = { onClickRedeem() }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 46.dp) - .padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - top = 24.dp, - bottom = PaddingDefaults.Medium - ) - ) { - Text( - stringResource(R.string.pres_detail_medication_redeem_button_text).uppercase(Locale.getDefault()), - modifier = Modifier.padding( - horizontal = PaddingDefaults.Medium, - vertical = PaddingDefaults.Small - ) - ) - } - } } @Composable private fun LowDetailRedeemHeader( prescriptionDetail: UIPrescriptionDetailScanned, - onSwitchRedeemed: (redeem: Boolean, all: Boolean, protocolText: String) -> Unit + onSwitchRedeemed: (redeem: Boolean) -> Unit ) { Row( modifier = Modifier @@ -610,10 +524,8 @@ private fun LowDetailRedeemHeader( .padding(start = 16.dp, end = 16.dp, top = 32.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - RedeemedButton( prescriptionDetail.redeemedOn != null, - prescriptionDetail.unRedeemMorePossible, onSwitchRedeemed ) } @@ -638,42 +550,9 @@ private fun LowDetailRedeemHeader( @Composable private fun RedeemedButton( redeemed: Boolean, - unRedeemMorePossible: Boolean, - onSwitchRedeemed: (redeem: Boolean, all: Boolean, protocolText: String) -> Unit + onSwitchRedeemed: (redeem: Boolean) -> 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) { + val buttonColors = if (redeemed) { ButtonDefaults.buttonColors( backgroundColor = AppTheme.colors.neutral050, contentColor = AppTheme.colors.primary700 @@ -685,27 +564,15 @@ private fun RedeemedButton( ) } - val buttonText = if (currentRedeemed) { + val buttonText = if (redeemed) { 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) - } + onSwitchRedeemed(!redeemed) }, colors = buttonColors, shape = RoundedCornerShape(8.dp), @@ -720,96 +587,215 @@ private fun RedeemedButton( } @Composable -private fun UnRedeemPrescriptionDialog( - onSwitchRedeemed: (redeem: Boolean, all: Boolean, protocol: String) -> Unit, -) { - val unRedeemProtocolText = stringResource(R.string.un_redeem_protocol_text) +fun PZNMedicationInformation(medication: SyncedTaskData.MedicationPZN) { + Column { + SubHeader( + text = medication.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 - ) - } - ) { - Text(stringResource(R.string.pres_detail_un_redeem_all).uppercase(Locale.getDefault())) - } - }, - properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) - ) + CategoryLabel(medication.category) + VaccineLabel(medication.vaccine) + + FormLabel(medication.form) + + NormSizeLabel(medication.normSizeCode) + + medication.amount?.let { AmountLabel(it) } + + PZNLabel(medication.uniqueIdentifier) + + LotNumberLabel(medication.lotNumber) + + ExpirationDateLabel(medication.expirationDate) + } } @Composable -private fun MedicationInformation( - state: UIPrescriptionDetailSynced, - isSubstituted: Boolean -) { +fun IngredientMedicationInformation(medication: SyncedTaskData.MedicationIngredient) { + Column { + IngredientInformation(medication.ingredients[0]) - val medicationType = if (isSubstituted) { - state.medicationDispense?.type?.let { codeToDosageFormMapping[it] } - ?.let { stringResource(it) } ?: MISSING_VALUE - } else { - state.medication.type?.let { stringResource(it) } ?: MISSING_VALUE + CategoryLabel(medication.category) + + VaccineLabel(medication.vaccine) + + FormLabel(medication.form) + + NormSizeLabel(medication.normSizeCode) + + medication.amount?.let { AmountLabel(it) } + + LotNumberLabel(medication.lotNumber) + + ExpirationDateLabel(medication.expirationDate) } +} - val uniqueIdentifier = if (isSubstituted) { - state.medicationDispense?.uniqueIdentifier ?: MISSING_VALUE - } else { - state.medication.uniqueIdentifier ?: MISSING_VALUE +@Composable +fun CompoundingMedicationInformation(medication: SyncedTaskData.MedicationCompounding) { + Column { + SubHeader( + text = medication.form ?: stringResource(R.string.pres_detail_medication_compounding) + ) + + CategoryLabel(medication.category) + + VaccineLabel(medication.vaccine) + + medication.amount?.let { AmountLabel(it) } + + SubHeader(stringResource(R.string.pres_detail_medication_ingredents_header)) + + medication.ingredients.forEach { + IngredientInformation(it) + } + + LotNumberLabel(medication.lotNumber) + + ExpirationDateLabel(medication.expirationDate) } +} - SubHeader( - text = stringResource(id = R.string.pres_detail_medication_header) +@Composable +fun FreeTextMedicationInformation(medication: SyncedTaskData.MedicationFreeText) { + Column { + SubHeader( + text = medication.text + ) + + CategoryLabel(medication.category) + VaccineLabel(medication.vaccine) + + FormLabel(medication.form) + + LotNumberLabel(medication.lotNumber) + + ExpirationDateLabel(medication.expirationDate) + } +} + +@Composable +fun PZNLabel(uniqueIdentifier: String) { + Label( + text = uniqueIdentifier, + label = stringResource(id = R.string.pres_detail_medication_label_id) ) +} + +@Composable +fun ExpirationDateLabel(expirationDate: Instant?) { + expirationDate?.let { + Label( + text = dateTimeShortText(expirationDate), + label = stringResource(id = R.string.pres_detail_medication_label_expiration_date) + ) + } +} + +@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( - text = medicationType, - label = stringResource(id = R.string.pres_detail_medication_label_dosage_form) + text = text, + label = stringResource(id = R.string.pres_detail_medication_label_category) ) +} + +@Composable +fun VaccineLabel(isVaccine: Boolean) { + if (isVaccine) { + Text( + text = stringResource(id = R.string.pres_detail_medication_vaccine), + style = MaterialTheme.typography.body1 + ) + } +} + +@Composable +fun LotNumberLabel(lotNumber: String?) { + lotNumber?.let { number -> + Label( + text = number, + label = stringResource(id = R.string.pres_detail_medication_label_lot_number) + ) + } +} +@Composable +fun AmountLabel(amount: SyncedTaskData.Ratio) { 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 - } - } else { - MISSING_VALUE - }, - label = stringResource(id = R.string.pres_detail_medication_label_normsize) + text = amount.numerator?.value + " " + amount.numerator?.unit, + label = stringResource(id = R.string.pres_detail_medication_label_amount) ) +} + +@Composable +fun IngredientAmountLabel(amount: String?) { + amount?.let { + Label( + text = it, + label = stringResource(id = R.string.pres_detail_medication_label_ingredient_amount) + ) + } +} + +@Composable +fun FormLabel(form: String?) { + codeToFormMapping[form]?.let { resourceId -> + stringResource(resourceId) + } ?: form?.let { + Label( + text = it, + label = stringResource(id = R.string.pres_detail_medication_label_dosage_form) + ) + } +} +@Composable +fun IngredientInformation(ingredient: SyncedTaskData.Ingredient) { + IngredientNameLabel(ingredient.text) + IngredientAmountLabel(ingredient.amount) + FormLabel(ingredient.form) + ingredient.strength?.let { StrengtLabel(it) } +} + +@Composable +fun IngredientNameLabel(text: String) { Label( - text = uniqueIdentifier, - label = stringResource(id = R.string.pres_detail_medication_label_id) + text = text, + label = stringResource(id = R.string.pres_detail_medication_label_ingredient_name) ) } @Composable -private fun WasSubstitutedHint() { +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) + ) + } +} + +@Composable +fun NormSizeLabel(normSizeCode: String?) { + normSizeCode?.let { code -> + normSizeMapping[code]?.let { resourceId -> + Label( + text = "$code - ${stringResource(resourceId)}", + label = stringResource(id = R.string.pres_detail_medication_label_normsize) + ) + } + } +} +@Composable +private fun WasSubstitutedHint() { HintCard( modifier = Modifier.padding(PaddingDefaults.Medium), properties = HintCardDefaults.properties( @@ -830,12 +816,12 @@ private fun WasSubstitutedHint() { } @Composable -private fun ColumnScope.DosageInformation( +private fun DosageInformation( state: UIPrescriptionDetailSynced, isSubstituted: Boolean ) { val infoText = if (isSubstituted) { - state.medicationDispense?.dosageInstruction + state.medicationDispenses.firstOrNull()?.dosageInstruction ?: stringResource(id = R.string.pres_detail_dosage_default_info) } else { state.medicationRequest.dosageInstruction @@ -849,7 +835,7 @@ private fun ColumnScope.DosageInformation( modifier = Modifier.padding(start = PaddingDefaults.Medium, end = PaddingDefaults.Medium), image = { HintSmallImage(painterResource(R.drawable.doctor_circle), innerPadding = it) }, title = null, - body = { Text(infoText) }, + body = { Text(infoText) } ) } @@ -859,7 +845,7 @@ fun ColumnScope.HealthPortalLink() { Text( modifier = Modifier.padding(horizontal = PaddingDefaults.Medium), text = stringResource(id = R.string.pres_detail_health_portal_description), - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2, color = AppTheme.typographyColors.body2l ) Spacer8() @@ -868,6 +854,7 @@ fun ColumnScope.HealthPortalLink() { val uriHandler = LocalUriHandler.current val annotatedLink = annotatedLinkStringLight(link, linkInfo) + ClickableText( text = annotatedLink, onClick = { @@ -885,12 +872,9 @@ fun ColumnScope.HealthPortalLink() { @Composable private fun PatientInformation( - patient: PatientDetail, - insurance: InsuranceCompanyDetail + patient: SyncedTaskData.Patient, + insurance: SyncedTaskData.InsuranceInformation ) { - val dtFormatter = - remember(LocalConfiguration.current) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } - SubHeader( text = stringResource(id = R.string.pres_detail_patient_header) ) @@ -901,12 +885,20 @@ private fun PatientInformation( ) Label( - text = patient.address ?: MISSING_VALUE, + text = patient.address?.joinToString() ?: MISSING_VALUE, label = stringResource(id = R.string.pres_detail_patient_label_address) ) Label( - text = patient.birthdate?.format(dtFormatter) ?: MISSING_VALUE, + text = remember(LocalConfiguration.current, patient) { + val dtFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + + patient.birthdate?.let { + LocalDateTime.ofInstant(it, ZoneId.systemDefault()) + .toLocalDate() + .format(dtFormatter) + } ?: MISSING_VALUE + }, label = stringResource(id = R.string.pres_detail_patient_label_birthdate) ) @@ -916,7 +908,7 @@ private fun PatientInformation( ) Label( - text = insurance.status?.let { stringResource(it) } ?: MISSING_VALUE, + text = insurance.status?.let { statusMapping[it]?.let { stringResource(it) } } ?: MISSING_VALUE, label = stringResource(id = R.string.pres_detail_patient_label_member_status) ) @@ -928,7 +920,7 @@ private fun PatientInformation( @Composable private fun PractitionerInformation( - practitioner: PractitionerDetail + practitioner: SyncedTaskData.Practitioner ) { SubHeader( text = stringResource(id = R.string.pres_detail_practitioner_header) @@ -952,7 +944,7 @@ private fun PractitionerInformation( @Composable private fun OrganizationInformation( - organization: OrganizationDetail + organization: SyncedTaskData.Organization ) { SubHeader( text = stringResource(id = R.string.pres_detail_organization_header) @@ -964,7 +956,7 @@ private fun OrganizationInformation( ) Label( - text = organization.address ?: MISSING_VALUE, + text = organization.address?.joinToString() ?: MISSING_VALUE, label = stringResource(id = R.string.pres_detail_organization_label_address) ) @@ -986,18 +978,21 @@ private fun OrganizationInformation( @Composable private fun AccidentInformation( - medicationRequest: MedicationRequestDetail + medicationRequest: SyncedTaskData.MedicationRequest ) { - 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, + text = remember(LocalConfiguration.current, medicationRequest.dateOfAccident) { + val dtFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + medicationRequest.dateOfAccident?.let { + LocalDateTime.ofInstant(it, ZoneId.systemDefault()) + .toLocalDate() + .format(dtFormatter) + } ?: MISSING_VALUE + }, label = stringResource(id = R.string.pres_detail_accident_label_date) ) @@ -1007,33 +1002,6 @@ private fun AccidentInformation( ) } -@Composable -private fun ProtocolScanned( - uiPrescriptionDetail: UIPrescriptionDetailScanned, - lowDetailRedeemEvents: List -) { - val firstScan = stringResource( - R.string.scanned_prescription_detail_protocol_scanned_at, - uiPrescriptionDetail.formattedScannedInfo(stringResource(R.string.at)) - ) - - SubHeader( - text = stringResource(id = R.string.scanned_prescription_detail_protocol_header) - ) - - Label( - text = firstScan, - label = stringResource(id = R.string.scanned_prescription_detail_protocol_scanned_label) - ) - - lowDetailRedeemEvents.map { - Label( - text = phrasedDateString(it.timestamp.toLocalDateTime()), - label = it.text - ) - } -} - @Composable private fun TechnicalPrescriptionInformation(accessCode: String?, taskId: String) { SubHeader(stringResource(R.string.pres_detail_technical_information)) @@ -1076,12 +1044,12 @@ private fun Label( ) { Text( text = text, - style = MaterialTheme.typography.body1 + style = AppTheme.typography.body1 ) Spacer4() Text( text = label, - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2, color = AppTheme.typographyColors.body2l ) } @@ -1092,7 +1060,7 @@ private fun Header( text: String ) = Text( text = text, - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, fontWeight = FontWeight(500), modifier = Modifier.padding( start = PaddingDefaults.Medium, @@ -1107,7 +1075,7 @@ private fun SubHeader( ) = Text( text = text, - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1, fontWeight = FontWeight(500), modifier = Modifier.padding( top = 40.dp, @@ -1137,11 +1105,11 @@ private fun EmergencyServiceCard() { Column { Text( stringResource(R.string.pres_detail_noctu_header), - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) Text( stringResource(R.string.pres_detail_noctu_info), - style = MaterialTheme.typography.body2 + style = AppTheme.typography.body2 ) } } 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 61a3d1f0..b2572de1 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,50 +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.db.entities.LowDetailEventSimple import de.gematik.ti.erp.app.prescription.detail.ui.model.UIPrescriptionDetail 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()) { - prescriptionUseCase.deletePrescription(taskId, isRemoteTask).map { prescriptionUseCase.deleteLowDetailEvents(taskId) } - } - } + 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/model/Mapper.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Mapper.kt deleted file mode 100644 index cd532f8c..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/detail/ui/model/Mapper.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.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.PharmacyUseCaseData.PrescriptionOrder -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 -) = - PrescriptionOrder( - taskId = task.taskId, - accessCode = task.accessCode!!, - title = medication.text, - substitutionsAllowed = medicationRequest.substitutionAllowed - ) 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 index 641495b6..5929fdee 100644 --- 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 @@ -19,57 +19,46 @@ 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 +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier + +import java.time.Instant interface UIPrescriptionDetail { + val profileId: ProfileIdentifier val taskId: String - val redeemedOn: OffsetDateTime? + val redeemedOn: Instant? val accessCode: String? - val bitmapMatrix: BitMatrixCode? + val matrixPayload: String? } @Immutable data class UIPrescriptionDetailScanned( + override val profileId: ProfileIdentifier, override val taskId: String, - override val redeemedOn: OffsetDateTime?, + override val redeemedOn: Instant?, override val accessCode: String?, - override val bitmapMatrix: BitMatrixCode?, + override val matrixPayload: String?, 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) - } -} + val scannedOn: Instant +) : UIPrescriptionDetail @Immutable data class UIPrescriptionDetailSynced( + override val profileId: ProfileIdentifier, override val taskId: String, - override val redeemedOn: OffsetDateTime?, + override val redeemedOn: Instant?, 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? + override val matrixPayload: String?, + val state: SyncedTaskData.SyncedTask.TaskState, + val isRedeemableAndValid: Boolean, + val expiresOn: Instant?, + val acceptUntil: Instant?, + val patient: SyncedTaskData.Patient, + val practitioner: SyncedTaskData.Practitioner, + val insurance: SyncedTaskData.InsuranceInformation, + val organization: SyncedTaskData.Organization, + val medicationRequest: SyncedTaskData.MedicationRequest, + val medicationDispenses: List, + val taskStatus: SyncedTaskData.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..5bb83b17 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/model/SyncedTaskData.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 + +val CommunicationWaitStateDelta: Duration = Duration.ofMinutes(10) + +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, + 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 Pending(val sentOn: Instant) : TaskState + data class InProgress(val lastModified: Instant) : TaskState + data class Expired(val expiredOn: Instant) : TaskState + + data class Other(val state: TaskStatus) : TaskState + + fun state(now: Instant = Instant.now(), delta: Duration = CommunicationWaitStateDelta): TaskState = + when { + expiresOn != null && expiresOn < now -> Expired(expiresOn) + status == TaskStatus.Ready && + accessCode != null && + communications.any { it.profile == CommunicationProfile.ErxCommunicationDispReq } && + redeemState(now, delta) == RedeemState.NotRedeemable -> + Pending(sentOn = this.communications.maxOf { it.sentOn }) + status == TaskStatus.Ready -> Ready( + expiresOn = requireNotNull(expiresOn), + acceptUntil = requireNotNull(acceptUntil) + ) + status == TaskStatus.InProgress -> InProgress(lastModified = this.lastModified) + else -> Other(this.status) + } + + 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 = Duration.ofMinutes(10)): RedeemState { + val notExpired = (expiresOn != null && now <= expiresOn) || expiresOn == null + 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 { + !notExpired -> 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 medicationRequestMedicationName() = + when (medicationRequest.medication) { + is MedicationPZN -> medicationRequest.medication.text + is MedicationCompounding -> medicationRequest.medication.form + is MedicationIngredient -> medicationRequest.medication.ingredients[0].text + is MedicationFreeText -> medicationRequest.medication.text + + else -> "" + } + + 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 + ) + + 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 + ) + + 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? + ) + + data class Ingredient( + var text: String, + var form: 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: Instant? + } + + 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: Instant? + ) : 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: Instant?, + 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: Instant?, + 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: Instant?, + 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..58749748 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,706 @@ 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.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.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.realmListOf +import io.realm.kotlin.ext.toRealmList 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 org.hl7.fhir.r4.model.Address +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.PrimitiveType +import org.hl7.fhir.r4.model.StringType import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset -import javax.inject.Inject +import java.util.Date -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 + ) + + val mutex = Mutex() + + suspend fun saveTask(profileId: ProfileIdentifier, bundle: Bundle): SaveTaskResult? = mutex.withLock { + val task = bundle.extractResources().first() + + return realm.tryWrite { + queryFirst("id = $0", profileId)?.let { profile -> + val taskId = task.idElement.idPart + + val taskEntity = queryFirst("taskId = $0", taskId) + ?.apply { applyBasicTaskData(task) } + ?: run { + val kbvBundleReference = requireNotNull(task.extractKBVBundleReference()) + val kbvBundle = requireNotNull(bundle.extractKBVBundle(kbvBundleReference)) + + var _fhirMedication: FhirMedication? = null + var _fhirMedicationRequest: FhirMedicationRequest? = null + var _fhirOrganization: FhirOrganization? = null + var _fhirPractitioner: FhirPractitioner? = null + var _fhirPatient: FhirPatient? = null + var _fhirInsuranceInformation: FhirCoverage? = 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 + is FhirPatient -> _fhirPatient = resource + is FhirCoverage -> _fhirInsuranceInformation = resource + } + } + + val medication = requireNotNull(_fhirMedication) + val medicationRequest = requireNotNull(_fhirMedicationRequest) + val organization = requireNotNull(_fhirOrganization) + val practitioner = requireNotNull(_fhirPractitioner) + val patient = requireNotNull(_fhirPatient) + val insuranceInformation = requireNotNull(_fhirInsuranceInformation) + + copyToRealm( + SyncedTaskEntityV1().apply { + this.applyBasicTaskData(task) + this.medicationRequest = + medicationRequest.toMedicationRequestEntityV1(medication.toMedicationEntityV1()) + this.organization = organization.toOrganizationEntityV1() + this.practitioner = practitioner.toPractitionerEntityV1() + this.patient = patient.toPatientEntityV1() + this.insuranceInformation = insuranceInformation.toInsuranceInformationEntityV1() + this.parent = profile + } + ).also { + profile.syncedTasks += it + } + } + + // delete scanned task + queryFirst("taskId = $0", taskId)?.let { delete(it) } + + SaveTaskResult( + isCompleted = taskEntity.status == TaskStatusV1.Completed, + lastModified = taskEntity.lastModified.toInstant() + ) + } + } } - suspend fun saveCommunications(communications: List) { - db.withTransaction { - val tasks = db.taskDao().getAllTasksWithTaskIdOnly() - val communicationsWithTask = communications.filter { - it.taskId in tasks + fun loadSyncedTasks(profileId: ProfileIdentifier): Flow> = + realm.query("id = $0", profileId) + .first() + .asFlow() + .map { profile -> + profile.obj?.syncedTasks?.map { syncedTask -> + syncedTask.toSyncedTask() + } ?: emptyList() + } + + fun loadSyncedTaskByTaskId(taskId: String): Flow = + realm.query("taskId = $0", taskId) + .first() + .asFlow() + .map { syncedTask -> + syncedTask.obj?.toSyncedTask() + } + + fun loadScannedTaskByTaskId(taskId: String): Flow = + realm.query("taskId = $0", taskId) + .first() + .asFlow() + .map { scannedTask -> + scannedTask.obj?.toScannedTask() + } + + suspend fun saveCommunications(bundle: Bundle) { + val communications = bundle.extractResources() + realm.tryWrite { + communications.forEach { communication -> + val taskId = communication.basedOn[0].reference.split("/")[1] + val communicationAlreadyExists = + query("communicationId = $0", communication.idElement.idPart).count() + .find() > 0 + if (!communicationAlreadyExists) { + queryFirst("taskId = $0", taskId)?.let { syncedTask -> + syncedTask.communications += + copyToRealm(communication.toCommunicationEntityV1(syncedTask)) + } + } } - db.communicationsDao() - .insertMultipleCommunications(*communicationsWithTask.toTypedArray()) } } - suspend fun saveAuditEvents(auditEvents: List) { - db.taskDao().insertAuditEvents(*auditEvents.toTypedArray()) + suspend fun saveMedicationDispense(taskId: String, bundle: MedicationDispense) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.let { syncedTask -> + if (query("dispenseId = $0", bundle.idElement.idPart) + .count() + .find() == 0L + ) { + syncedTask.medicationDispenses += copyToRealm(bundle.toMedicationDispenseEntityV1()) + } + } + } } - suspend fun saveMedicationDispense(medicationDispense: MedicationDispenseSimple) { - db.taskDao().insertMedicationDispenses(medicationDispense) + fun loadScannedTasks(profileId: ProfileIdentifier): Flow> = + realm.query("id = $0", profileId) + .first() + .asFlow() + .map { profile -> + profile.obj?.let { + it.scannedTasks.map { task -> + task.toScannedTask() + } + } ?: emptyList() + } + + suspend fun deleteTask(taskId: String) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.let { delete(it) } + queryFirst("taskId = $0", taskId)?.let { + deleteAll(it) + } + } } - suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) { - db.taskDao().insertLowDetailEvent(lowDetailEvent) + suspend fun updateRedeemedOn(taskId: String, timestamp: Instant?) { + realm.tryWrite { + queryFirst("taskId = $0", taskId)?.apply { + this.redeemedOn = timestamp?.toRealmInstant() + } + } } - fun loadLowDetailEvents(taskId: String): Flow> = - db.taskDao().getLowDetailEvents(taskId) + fun loadTaskIds(): Flow> = + combine( + realm.query().asFlow(), + realm.query().asFlow() + ) { syncedTasks, scannedTasks -> + syncedTasks.list.map { it.taskId } + scannedTasks.list.map { it.taskId } + } - fun deleteLowDetailEvents(taskId: String) { - db.taskDao().deleteLowDetailEvents(taskId) + suspend fun updateTaskSyncedUpTo(profileId: ProfileIdentifier, timestamp: Instant) { + realm.tryWrite { + queryFirst("id = $0", profileId)?.apply { + this.lastTaskSynced = timestamp.toRealmInstant() + } + } } - fun loadTasks(profileName: String): Flow> { - return db.taskDao().getAllTasks(profileName) - } + fun taskSyncedUpTo(profileId: ProfileIdentifier): Flow = + realm.query("id = $0", profileId) + .first() + .asFlow() + .map { profile -> + profile.obj?.lastTaskSynced?.toInstant() + } +} - fun loadScannedTasksWithoutBundle(profileName: String): Flow> = - db.taskDao().getScannedTasksWithoutBundle(profileName) +fun FhirTaskStatus.toTaskStatusV1() = + when (this) { + FhirTaskStatus.READY -> TaskStatusV1.Ready + FhirTaskStatus.INPROGRESS -> TaskStatusV1.InProgress + FhirTaskStatus.COMPLETED -> TaskStatusV1.Completed + FhirTaskStatus.DRAFT -> TaskStatusV1.Draft + FhirTaskStatus.REQUESTED -> TaskStatusV1.Requested + FhirTaskStatus.RECEIVED -> TaskStatusV1.Received + FhirTaskStatus.ACCEPTED -> TaskStatusV1.Accepted + FhirTaskStatus.REJECTED -> TaskStatusV1.Rejected + FhirTaskStatus.CANCELLED -> TaskStatusV1.Canceled + FhirTaskStatus.ONHOLD -> TaskStatusV1.OnHold + FhirTaskStatus.FAILED -> TaskStatusV1.Failed + else -> TaskStatusV1.Other + } - fun loadSyncedTasksWithoutBundle(profileName: String): Flow> = - db.taskDao().getSyncedTasksWithoutBundle(profileName) +fun FhirPatient.toPatientEntityV1() = + PatientEntityV1().apply { + this.name = this@toPatientEntityV1.name.find { it.use == HumanName.NameUse.OFFICIAL }?.nameAsSingleString + this.address = this@toPatientEntityV1.address.find { it.type == Address.AddressType.BOTH }?.toAddressEntityV1() + this.birthdate = this@toPatientEntityV1.birthDate?.toInstant()?.toRealmInstant() + this.insuranceIdentifier = this@toPatientEntityV1.identifier.firstOrNull()?.value + } - fun loadTaskWithMedicationDispenseForTaskId(taskId: String): Flow { - return db.taskDao().getTaskWithMedicationDispenseForTaskId(taskId) +fun FhirAddress.toAddressEntityV1() = + AddressEntityV1().apply { + this.line1 = this@toAddressEntityV1.line.getOrNull(0)?.value ?: "" + this.line2 = this@toAddressEntityV1.line.getOrNull(1)?.value ?: "" + this.postalCodeAndCity = this@toAddressEntityV1.postalCode + " " + this@toAddressEntityV1.city } - fun loadTasksForTaskId(vararg taskIds: String): Flow> { - return db.taskDao().getTasksForTaskId(*taskIds) +fun FhirCoverage.toInsuranceInformationEntityV1() = + InsuranceInformationEntityV1().apply { + this.name = this@toInsuranceInformationEntityV1.payorFirstRep?.display + this.statusCode = + this@toInsuranceInformationEntityV1.getCodeValueExtensionByUrl( + "http://fhir.de/StructureDefinition/gkv/versichertenart" + ) } - suspend fun deleteTaskByTaskId(taskId: String) { - db.taskDao().deleteTaskByTaskId(taskId) +fun FhirOrganization.toOrganizationEntityV1() = + OrganizationEntityV1().apply { + this.name = this@toOrganizationEntityV1.name + this.address = + this@toOrganizationEntityV1.address.find { it.type == Address.AddressType.BOTH }?.toAddressEntityV1() + this.uniqueIdentifier = + this@toOrganizationEntityV1.identifier?.find { + it.system == "https://fhir.kbv.de/NamingSystem/KBV_NS_Base_BSNR" + }?.value + this.phone = + this@toOrganizationEntityV1.telecom?.find { it.system == ContactPoint.ContactPointSystem.PHONE }?.value + this.mail = + this@toOrganizationEntityV1.telecom?.find { it.system == ContactPoint.ContactPointSystem.EMAIL }?.value } - suspend fun updateRedeemedOnForAllTasks(taskIds: List, tm: OffsetDateTime?) { - db.taskDao().updateRedeemedOnForAllTasks(taskIds, tm) +fun FhirPractitioner.toPractitionerEntityV1() = + PractitionerEntityV1().apply { + this.name = this@toPractitionerEntityV1.name.find { it.use == HumanName.NameUse.OFFICIAL }?.nameAsSingleString + this.qualification = this@toPractitionerEntityV1.qualification.find { it.code?.hasText() == true }?.code?.text + this.practitionerIdentifier = this@toPractitionerEntityV1.identifier.firstOrNull()?.value } - suspend fun updateRedeemedOnForSingleTask(taskId: String, tm: OffsetDateTime?) { - db.taskDao().updateRedeemedOnForSingleTask(taskId, tm) +fun FhirRatio.toRatioEntityV1() = + RatioEntityV1().apply { + this.numerator = this@toRatioEntityV1.numerator?.toQuantityEntityV1() + this.denominator = this@toRatioEntityV1.denominator?.toQuantityEntityV1() } - fun loadTasksForRedeemedOn(redeemedOn: OffsetDateTime, profileName: String): Flow> { - return db.taskDao().loadTasksForRedeemedOn(redeemedOn, profileName) +fun FhirQuantity.toQuantityEntityV1() = + QuantityEntityV1().apply { + this.value = this@toQuantityEntityV1.value?.toString() ?: "" + this.unit = this@toQuantityEntityV1.unit ?: "" } - suspend fun getAllTasksWithTaskIdOnly(profileName: String): List { - return db.taskDao().getAllTasksWithTaskIdOnly(profileName) +fun FhirMedication.toMedicationEntityV1() = + MedicationEntityV1().apply { + this.text = this@toMedicationEntityV1.code.text ?: "" + this.medicationProfile = when (this@toMedicationEntityV1.meta.profile[0].value.split("|").first()) { + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_PZN" -> MedicationProfileV1.PZN + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Compounding" -> + MedicationProfileV1.COMPOUNDING + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_Ingredient" -> MedicationProfileV1.INGREDIENT + "https://fhir.kbv.de/StructureDefinition/KBV_PR_ERP_Medication_FreeText" -> MedicationProfileV1.FREETEXT + else -> error("empty medication profile") + } + + this.medicationCategory = + when ( + this@toMedicationEntityV1.getCodeValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Category" + ) + ) { + "00" -> MedicationCategoryV1.ARZNEI_UND_VERBAND_MITTEL + "01" -> MedicationCategoryV1.BTM + "02" -> MedicationCategoryV1.AMVV + else -> error("unknown medication category") + } + this.amount = this@toMedicationEntityV1.amount.toRatioEntityV1() + this.form = + ( + this@toMedicationEntityV1.form?.coding?.find { + it.system == "https://fhir.kbv.de/CodeSystem/KBV_CS_SFHIR_KBV_DARREICHUNGSFORM" + }?.code + ) ?: this@toMedicationEntityV1.form.text + this.vaccine = + this@toMedicationEntityV1.getValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Vaccine" + ) + ?: false + this.manufacturingInstructions = + this@toMedicationEntityV1.getValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_CompoundingInstruction" + ) + this.packaging = + this@toMedicationEntityV1.getValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Packaging" + ) + this.normSizeCode = + this@toMedicationEntityV1.getValueExtensionByUrl( + "http://fhir.de/StructureDefinition/normgroesse" + ) + this.uniqueIdentifier = + this@toMedicationEntityV1.code?.coding?.find { it.system == "http://fhir.de/CodeSystem/ifa/pzn" }?.code + this.lotNumber = this@toMedicationEntityV1.batch.lotNumber + this.expirationDate = + this@toMedicationEntityV1.batch.expirationDate?.let { it.toInstant().toRealmInstant() } + this.ingredients = this@toMedicationEntityV1.ingredient?.map { + IngredientEntityV1().apply { + this.text = it.itemCodeableConcept.text + this.form = + it.getValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Ingredient_Form" + ) + this.amount = + it.strength.getValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Medication_Ingredient_Amount" + ) + this.strength = it.strength.toRatioEntityV1() + } + }?.toRealmList() ?: realmListOf() } - fun updateScanSessionName(name: String?, scanSessionEnd: OffsetDateTime) { - db.taskDao().updateScanSessionName(name, scanSessionEnd) +fun FhirMedicationRequest.toMedicationRequestEntityV1(medication: MedicationEntityV1) = + MedicationRequestEntityV1().apply { + this.medication = medication + this.dateOfAccident = + this@toMedicationRequestEntityV1.getExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident" + ) + ?.getValueExtensionByUrl("unfalltag") + ?.toInstant() + ?.toRealmInstant() + this.location = + this@toMedicationRequestEntityV1.getExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_Accident" + ) + ?.getValueExtensionByUrl("unfallbetrieb") + this.emergencyFee = + this@toMedicationRequestEntityV1.getValueExtensionByUrl( + "https://fhir.kbv.de/StructureDefinition/KBV_EX_ERP_EmergencyServicesFee" + ) + this.substitutionAllowed = + this@toMedicationRequestEntityV1.substitution.allowedBooleanType.booleanValue() + this.dosageInstruction = this@toMedicationRequestEntityV1.extractDosageInstructions() } - fun loadCommunications( - profile: CommunicationProfile, - userProfile: String - ): Flow> { - return db.communicationsDao().getAllCommunications(profile, userProfile) +fun FhirCommunication.toCommunicationEntityV1(parent: SyncedTaskEntityV1) = + CommunicationEntityV1().apply { + this.profile = when { + this@toCommunicationEntityV1.isProfile( + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationDispReq" + ) -> + CommunicationProfileV1.ErxCommunicationDispReq + this@toCommunicationEntityV1.isProfile( + "https://gematik.de/fhir/StructureDefinition/ErxCommunicationReply" + ) -> + CommunicationProfileV1.ErxCommunicationReply + else -> CommunicationProfileV1.Unknown // we can't handle other profiles currently + } + this.taskId = this@toCommunicationEntityV1.basedOn[0].reference.split("/")[1] + this.communicationId = this@toCommunicationEntityV1.idElement.idPart.toString() + this.orderId = if (this.profile == CommunicationProfileV1.ErxCommunicationDispReq) { + this@toCommunicationEntityV1.identifier + .find { it.system == "https://gematik.de/fhir/NamingSystem/OrderID" }?.value ?: "" + } else { + "" + } + this.sentOn = this@toCommunicationEntityV1.sent.toInstant().toRealmInstant() + this.sender = this@toCommunicationEntityV1.sender.identifier.value + this.recipient = this@toCommunicationEntityV1.recipient[0].identifier.value + this.payload = this@toCommunicationEntityV1.payload[0].content.toString() + this.consumed = false + this.parent = parent } - fun loadUnreadCommunications( - profile: CommunicationProfile, - userProfile: String - ): Flow> { - return db.communicationsDao() - .getAllUnreadCommunications(profile = profile, userProfile = userProfile) +fun FhirResource.isProfile(name: String) = + this.meta.profile[0].value.split("|").first() == name + +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 +} + +inline fun , R> FhirElement.getValueExtensionByUrl(url: String): R? = + (getExtensionByUrl(url)?.value as? T?)?.value + +inline fun , R> DomainResource.getValueExtensionByUrl(url: String): R? = + (getExtensionByUrl(url)?.value as? T?)?.value - suspend fun setCommunicationsAcknowledgedStatus(communicationId: String, consumed: Boolean) { - db.communicationsDao().updateCommunication(communicationId, consumed) +fun DomainResource.getCodeValueExtensionByUrl(url: String): String? = + (getExtensionByUrl(url)?.value as? Coding?)?.code + +fun FhirMedicationDispense.toMedicationDispenseEntityV1() = + MedicationDispenseEntityV1().apply { + this.dispenseId = this@toMedicationDispenseEntityV1.idElement.idPart + this.patientIdentifier = this@toMedicationDispenseEntityV1.subject.identifier.value + this.medication = + (this@toMedicationDispenseEntityV1.contained[0] as FhirMedication).toMedicationEntityV1() + this.wasSubstituted = this@toMedicationDispenseEntityV1.substitution.wasSubstituted + this.dosageInstruction = this@toMedicationDispenseEntityV1.dosageInstruction.firstOrNull()?.text + this.performer = ( + this@toMedicationDispenseEntityV1.performer[0] as + MedicationDispense.MedicationDispensePerformerComponent + ) + .actor.identifier.value + this.whenHandedOver = this@toMedicationDispenseEntityV1.whenHandedOver.toInstant().toRealmInstant() } - suspend fun updateTaskSyncedUpTo(profileName: String, timestamp: Instant?) { - db.profileDao().updateLastTaskSynced(profileName, timestamp) +fun SyncedTaskEntityV1.toSyncedTask(): SyncedTaskData.SyncedTask = + SyncedTaskData.SyncedTask( + profileId = this.parent!!.id, + 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 + ), + 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() + } + ) + +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?.toInstant(), + 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?.toInstant(), + manufacturingInstructions = this.manufacturingInstructions, + packaging = this.packaging, + amount = this.amount.toRatio(), + ingredients = this.ingredients.toIngredients() + ) + + MedicationProfileV1.INGREDIENT -> SyncedTaskData.MedicationIngredient( + category = this.medicationCategory.toMedicationCategory(), + vaccine = this.vaccine, + text = this.text, + form = this.form, + lotNumber = this.lotNumber, + expirationDate = this.expirationDate?.toInstant(), + 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?.toInstant() + ) + 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 + ) + } + ) +} + +private fun RealmList.toIngredients(): List = + this.map { + SyncedTaskData.Ingredient( + text = it.text, + form = it.form, + 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() + ) + +fun SyncedTaskEntityV1.applyBasicTaskData(task: FhirTask) { + this.taskId = task.idElement.idPart + this.accessCode = task.accessCode() + this.lastModified = task.lastModified.toInstant().toRealmInstant() + this.status = task.status.toTaskStatusV1() + this.expiresOn = + task.extractDateExtension("https://gematik.de/fhir/StructureDefinition/ExpiryDate") + ?.toRealmInstant() + this.acceptUntil = + task.extractDateExtension("https://gematik.de/fhir/StructureDefinition/AcceptDate") + ?.toRealmInstant() + this.authoredOn = task.authoredOn.toInstant().toRealmInstant() } 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 8de3df28..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/Mapper.kt +++ /dev/null @@ -1,464 +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.ShippingContactEntity -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 = fhirTask.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, - @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 FhirPatient.mapToShippingContact(): ShippingContactEntity { - require(this.meta.profile[0].value == "https://fhir.kbv.de/StructureDefinition/KBV_PR_FOR_Patient|1.0.3") - val address = this.address.find { it.type == Address.AddressType.BOTH }!! - val line1 = address.line[0].value - val line2 = address.line.getOrNull(1)?.value ?: "" - return ShippingContactEntity( - name = this.name[0].nameAsSingleString, - line1 = line1, - line2 = line2, - postalCodeAndCity = address.postalCode + " " + address.city - ) -} - -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.extractShippingContact() = this.extractResources()?.firstOrNull()?.mapToShippingContact() - -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 7457e074..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionDemoDataSource.kt +++ /dev/null @@ -1,255 +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.db.entities.TaskStatus -import de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import de.gematik.ti.erp.app.pharmacy.usecase.model.PharmacyUseCaseData -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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ), - 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(), - status = TaskStatus.Ready - ) -) - -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") -) - -private val demoContact = PharmacyUseCaseData.ShippingContact( - "Max Mustermann", - "Mustermann Straße", - "10", - "12345", - "0123/456789", - "max.mustermann@email.de", "" -) - -@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 - } - - fun getDemoContact() = demoContact -} 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 25e4b325..63387775 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,27 +19,19 @@ package de.gematik.ti.erp.app.prescription.repository import de.gematik.ti.erp.app.DispatchProvider -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.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 java.time.Instant enum class RemoteRedeemOption(val type: String) { Local(type = "onPremise"), @@ -49,208 +41,124 @@ 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: Communication, + 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) + suspend fun downloadCommunications(profileIdentifier: ProfileIdentifier): Result = + remoteDataSource.fetchCommunications(profileIdentifier).map { bundle -> + withContext(dispatchers.IO) { + localDataSource.saveCommunications(bundle) } } /** * 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()) { - val results = taskIds.map { taskId -> - async { - downloadTaskWithKBVBundle(taskId, profileName).mapCatching { - deleteLowDetailEvents(taskId) + suspend fun downloadTasks(profileId: ProfileIdentifier): Result = + remoteDataSource.fetchTasks(localDataSource.taskSyncedUpTo(profileId).first(), profileId) + .mapCatching { bundle -> + val taskIds = bundle.extractResources().map { + it.idElement.idPart + } - if (it.status == TaskStatus.Completed) { - downloadMedicationDispense( - profileName, - taskId - ) + 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) } - - requireNotNull(it.lastModified) } - } - }.awaitAll() + }.awaitAll() - // throw if any result is not parsed correctly - results.find { it.isFailure }?.getOrThrow() + // throw if any result is not parsed correctly + results.find { it.isFailure }?.getOrThrow() - val lastModified = results.map { it.getOrNull()!! } - lastModified.maxOrNull()?.let { - localDataSource.updateTaskSyncedUpTo(profileName, it.toInstant()) - } + val lastModified = results.map { it.getOrNull()!! } + lastModified.maxOrNull()?.let { + localDataSource.updateTaskSyncedUpTo(profileId, it) + } - // return number of bundles saved to db - lastModified.size + // return number of bundles saved to db + lastModified.size + } } } - } private suspend fun downloadTaskWithKBVBundle( taskId: String, - profileName: String - ): Result = - remoteDataSource.taskWithKBVBundle(profileName, taskId).mapCatching { - val task = mapper.mapFhirBundleToTaskWithKBVBundle(it, profileName) - localDataSource.saveTask(task) - task - } - - private val coroutineScope = CoroutineScope(dispatchProvider.io()) - private val mutex = Mutex() - - fun downloadAllAuditEvents( - profileName: String - ) { - coroutineScope.launch { - mutex.withLock { - while (true) { - val result = downloadAuditEvents( - profileName = profileName, - count = AUDIT_EVENT_PAGE_SIZE - ) - if (result.isFailure || result.getOrThrow() != AUDIT_EVENT_PAGE_SIZE) { - break - } - } - } - } - } - - private suspend fun downloadAuditEvents( - profileName: String, - count: Int? = null - ): Result { - val syncedUpTo = localDataSource.auditEventsSyncedUpTo(profileName) - return remoteDataSource.allAuditEvents( - profileName, - syncedUpTo, - count = count - ).mapCatching { - val auditEvents = mapper.mapFhirBundleToAuditEvents(profileName, it) - localDataSource.saveAuditEvents(auditEvents) - localDataSource.setAllAuditEventsSyncedUpTo(profileName) - auditEvents.size + 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 remoteDataSource.medicationDispense(profileName, taskId).mapCatching { - // FIXME cast can never succeed - mapper.mapMedicationDispenseToMedicationDispenseSimple(it as MedicationDispense) - .let { - localDataSource.saveMedicationDispense(it) - localDataSource.updateRedeemedOnForSingleTask( - taskId, - it.whenHandedOver - ) - } + ): Result = withContext(dispatchers.IO) { + remoteDataSource.loadBundleOfMedicationDispenses(profileId, taskId).map { + it.extractResources().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 { - return if (isRemoteTask) { - remoteDataSource.deleteTask(profileName, taskId) - } else { - Result.success(Unit) - }.map { localDataSource.deleteTaskByTaskId(taskId) } - } - - 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) + profileId: ProfileIdentifier, + taskId: String + ): Result = withContext(dispatchers.IO) { + remoteDataSource.deleteTask(profileId, taskId).map { + localDataSource.deleteTask(taskId) + } } - 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 582986af..4b40e3c8 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 @@ -20,75 +20,58 @@ package de.gematik.ti.erp.app.prescription.repository import de.gematik.ti.erp.app.api.ErpService 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 de.gematik.ti.erp.app.api.safeApiCallNullable +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Communication 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): Result = safeApiCall( errorMessage = "error getting communications" ) { - service.communication(profileName) + service.communication(profileId) } - suspend fun taskWithKBVBundle(profileName: String, taskID: String) = safeApiCall( + suspend fun taskWithKBVBundle(profileId: ProfileIdentifier, taskID: String) = safeApiCall( errorMessage = "error while downloading KBV Bundle $taskID" - ) { service.taskWithKBVBundle(profileName = profileName, id = taskID) } + ) { service.taskWithKBVBundle(profileId = profileId, 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, - count = count, - offset = offset - ) - } - - suspend fun medicationDispense(profileName: String, taskId: String) = safeApiCall( + 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( + suspend fun communicate(profileId: ProfileIdentifier, com: Communication, accessCode: String? = null) = safeApiCall( errorMessage = "error while posting communication" ) { - service.communication(profileName, communication = com) + service.communication(profileId, communication = com, accessCode = accessCode) } } 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 index 87ab94c7..3d3d7616 100644 --- 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 @@ -20,18 +20,15 @@ package de.gematik.ti.erp.app.prescription.ui import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -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.wrapContentHeight 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.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons @@ -41,81 +38,78 @@ 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.SpacerMedium import de.gematik.ti.erp.app.utils.compose.SpacerSmall -enum class EmptyScreenState(val index: Int) { - LoggedIn(0), - LoggedOut(1), - NoHealthCard(2), - NotEmpty(3); - - companion object { - fun ofValue(index: Int): EmptyScreenState? = values().find { it.index == index } - } -} +@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 EmptyScreenHome( +fun HomeConnectedWithoutToken( modifier: Modifier = Modifier, - header: String, - description: String, - image: @Composable () -> Unit, - button: @Composable () -> Unit, - index: Int, - displayedScreen: (EmptyScreenState) -> Unit -) { - displayedScreen(EmptyScreenState.ofValue(index)!!) - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - ) { - image() - SpacerMedium() - Text( - text = header, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().wrapContentHeight() - ) - SpacerSmall() - Text( - text = description, - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().wrapContentHeight() - ) - SpacerSmall() - button() - } -} + 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, - displayedScreen: (EmptyScreenState) -> Unit + onClickAction: () -> Unit ) = EmptyScreenHome( modifier = modifier, - index = 0, - displayedScreen = displayedScreen, 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), + modifier = Modifier.size(160.dp) ) }, button = { @@ -136,22 +130,19 @@ fun HomeHealthCardConnected( @Preview @Composable -fun HomeEGKRedeemedPreview() { +private fun HomeHealthCardConnectedPreview() { AppTheme { - HomeHealthCardConnected(onClickAction = {}, displayedScreen = { }) + HomeHealthCardConnected(onClickAction = {}) } } @Composable fun HomeHealthCardDisconnected( modifier: Modifier = Modifier, - onClickAction: () -> Unit, - displayedScreen: (EmptyScreenState) -> Unit + onClickAction: () -> Unit ) = EmptyScreenHome( modifier = modifier, - index = 1, - displayedScreen = displayedScreen, header = stringResource(R.string.home_egk_notredeemable_header), description = stringResource(R.string.home_egk_notredeemable_description), image = { @@ -178,29 +169,18 @@ fun HomeHealthCardDisconnected( @Preview @Composable -fun HomeEGKNotRedeemablePreview() { +private fun HomeHealthCardDisconnectedPreview() { AppTheme { - HomeHealthCardDisconnected(onClickAction = {}, displayedScreen = { }) - } -} - -@Preview -@Composable -fun HomeNoEGKInitialPreview() { - AppTheme { - HomeNoHealthCard(onClickAction = {}, displayedScreen = { }) + HomeHealthCardDisconnected(onClickAction = {}) } } @Composable fun HomeNoHealthCard( modifier: Modifier = Modifier, - onClickAction: () -> Unit, - displayedScreen: (EmptyScreenState) -> Unit + onClickAction: () -> Unit ) = EmptyScreenHome( modifier = modifier, - index = 2, - displayedScreen = displayedScreen, header = stringResource(R.string.home_noegk_initial_header), description = stringResource(R.string.home_noegk_initial_description), image = { @@ -223,42 +203,23 @@ fun HomeNoHealthCard( } ) +@Preview @Composable -fun EmptyScreenArchive( - modifier: Modifier = Modifier, - header: String, - description: String, -) { - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - ) { - Text( - text = header, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - SpacerSmall() - Text( - text = description, - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) +private fun HomeNoHealthCardPreview() { + AppTheme { + HomeNoHealthCard(onClickAction = {}) } } @Composable -fun ArchiveNoEGKInitial(modifier: Modifier = Modifier) = EmptyScreenArchive( +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 ArchiveNoEGKRedeemed(modifier: Modifier = Modifier) = EmptyScreenArchive( +fun ArchiveNoHealthCardRedeemed(modifier: Modifier = Modifier) = EmptyScreenArchive( modifier = modifier, header = stringResource(R.string.archive_noegk_redeemed_header), description = stringResource(R.string.archive_noegk_redeemed_description) @@ -266,17 +227,17 @@ fun ArchiveNoEGKRedeemed(modifier: Modifier = Modifier) = EmptyScreenArchive( @Preview @Composable -fun ArchiveNoEGKInitialPreview() { +private fun ArchiveNoEGKInitialPreview() { AppTheme { - ArchiveNoEGKInitial() + ArchiveNoHealthCardInitial() } } @Preview @Composable -fun ArchiveNoEGKRedeemedPreview() { +private fun ArchiveNoEGKRedeemedPreview() { AppTheme { - ArchiveNoEGKRedeemed() + ArchiveNoHealthCardRedeemed() } } @@ -293,22 +254,23 @@ fun HomeNoHealthCardSignInHint(onClickAction: () -> Unit) { ) { Text( text = stringResource(R.string.home_noegk_signin_hint_description), - modifier = Modifier.padding(vertical = PaddingDefaults.Medium) + modifier = Modifier + .padding(vertical = PaddingDefaults.Medium) .fillMaxWidth() .weight(1f), - style = MaterialTheme.typography.body2, + style = AppTheme.typography.body2 ) TextButton( onClick = onClickAction, shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors(backgroundColor = AppTheme.colors.primary600), - modifier = Modifier.align(Alignment.CenterVertically), + 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 = MaterialTheme.typography.button, + style = AppTheme.typography.button ) } } @@ -317,7 +279,7 @@ fun HomeNoHealthCardSignInHint(onClickAction: () -> Unit) { @Preview @Composable -fun SignInHintPreview() { +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/PrescriptionScreenComponents.kt b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/PrescriptionScreenComponents.kt index 0d47ef03..73582b66 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,617 +28,453 @@ 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.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.rememberSwipeableState -import androidx.compose.material.swipeable import androidx.compose.runtime.Composable -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.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.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.text.AnnotatedString 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.navigation.NavController import de.gematik.ti.erp.app.R -import de.gematik.ti.erp.app.core.LocalActivity -import de.gematik.ti.erp.app.demo.ui.DemoBanner 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.MlKitPermissionDialog import de.gematik.ti.erp.app.mainscreen.ui.PrescriptionTabs -import de.gematik.ti.erp.app.mainscreen.ui.PullRefreshState +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.profiles.ui.LocalProfileHandler 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.CommonAlertDialog +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.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.Instant import java.time.LocalDate 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 @Composable -fun SecureHardwarePrompt( - title: String, - description: String, - negativeButton: String, - onAuthenticate: () -> Unit, - onCancel: () -> Unit, +fun PrescriptionScreen( + navController: NavController, + prescriptionViewModel: PrescriptionViewModel, + listState: LazyListState, + selectedTab: PrescriptionTabs, + onEmptyScreenChange: (PrescriptionScreenData.EmptyActiveScreenState) -> 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) - - Timber.e("Failed to authenticate: $errString") + var showUserNotAuthenticatedDialog by remember { mutableStateOf(false) } - 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 }, + onShowCardWall = onShowCardWall + ) { onRefresh -> + Prescriptions( + prescriptionViewModel = prescriptionViewModel, + onClickRefresh = { + onRefresh(true, MutatePriority.UserInput) + }, + listState = listState, + navController = navController, + selectedTab = selectedTab, + onEmptyScreenChange = onEmptyScreenChange + ) + } +} - 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) +private val HeaderPaddingModifier = Modifier + .padding( + top = PaddingDefaults.Large, + bottom = PaddingDefaults.Medium, + start = PaddingDefaults.Medium, + end = PaddingDefaults.Medium + ) + .fillMaxWidth() + +private 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(), + onClickRefresh: () -> Unit, + listState: LazyListState, selectedTab: PrescriptionTabs, - uri: Uri?, - displayedScreen: (EmptyScreenState) -> Unit + onEmptyScreenChange: (PrescriptionScreenData.EmptyActiveScreenState) -> 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, - PullRefreshState.DemoLoggedIn -> { - refreshState.animateTo(true) - } - else -> { - refreshState.snapTo(false) - } - } - } - } - - 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 - }, - onCancel = { showSecureHardwarePrompt = false } - ) - } - - val state by produceState(prescriptionViewModel.defaultState) { + val state by produceState(null) { prescriptionViewModel.screenState().collect { value = it } } - LaunchedEffect(refreshState.currentValue) { - try { - if (refreshState.currentValue) { - prescriptionViewModel.refreshPrescriptions( - pullRefreshState = pullRefreshState, - isDemoModeActive = state.isDemoModeActive, - 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 - ) - } - } finally { - withContext(NonCancellable) { - pullRefreshState = PullRefreshState.None - refreshState.animateTo(false) - } - } - } - - PullRefresh(refreshState) { - val modifier = Modifier - Box { - Column(modifier = Modifier.fillMaxSize()) { - if (state.isDemoModeActive) { - DemoBanner { - mainViewModel.onDeactivateDemoMode() - } - } - - val coroutineScope = rememberCoroutineScope() - - Prescriptions( - onClickRefresh = { - coroutineScope.launch { refreshState.animateTo(true) } - }, - state = state, - navController = navController, - selectedTab = selectedTab, - uri = uri, - displayedScreen = displayedScreen - ) - } - // 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) - } + state?.let { + when (selectedTab) { + PrescriptionTabs.Redeemable -> ActivePrescriptionTab( + onClickRefresh = onClickRefresh, + state = it, + listState = listState, + navController = navController, + onEmptyScreenChange = onEmptyScreenChange + ) + PrescriptionTabs.Archive -> ArchivePrescriptionTab( + state = it, + listState = listState, + navController = navController + ) } } } +private val FabPadding = 68.dp + @Composable -private fun Prescriptions( +private fun ActivePrescriptionTab( + onClickRefresh: () -> Unit, state: PrescriptionScreenData.State, navController: NavController, - onClickRefresh: () -> Unit, - selectedTab: PrescriptionTabs, - uri: Uri?, - displayedScreen: (EmptyScreenState) -> Unit + listState: LazyListState, + onEmptyScreenChange: (PrescriptionScreenData.EmptyActiveScreenState) -> Unit ) { - 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() + val profileHandler = LocalProfileHandler.current + val emptyScreen = state.emptyActiveScreen(profileHandler.activeProfile) + var showMlKitPermissionDialog by remember { mutableStateOf(false) } - when (selectedTab) { - - PrescriptionTabs.Redeemable -> RedeemedTabInformation( - onClickRefresh = onClickRefresh, - state = state, - navController = navController, - cardPaddingModifier = cardPaddingModifier, - headerPaddingModifier = headerPaddingModifier, - displayedScreen = displayedScreen, - ) + LaunchedEffect(emptyScreen) { + onEmptyScreenChange(emptyScreen) + } - PrescriptionTabs.Archive -> ArchiveTabInformation( - state = state, - navController = navController, - cardPaddingModifier = cardPaddingModifier, - headerPaddingModifier = headerPaddingModifier + if (showMlKitPermissionDialog) { + MlKitPermissionDialog( + onAccept = { + navController.navigate(MainNavigationScreens.Camera.path()) + showMlKitPermissionDialog = false + }, + onDecline = { + showMlKitPermissionDialog = false + } ) } -} -@Composable -private fun RedeemedTabInformation( - onClickRefresh: () -> Unit, - state: PrescriptionScreenData.State, - navController: NavController, - cardPaddingModifier: Modifier, - headerPaddingModifier: Modifier, - displayedScreen: (EmptyScreenState) -> Unit, -) { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 68.dp), // padding for fab - horizontalAlignment = Alignment.CenterHorizontally + state = listState, + contentPadding = if (emptyScreen != PrescriptionScreenData.EmptyActiveScreenState.NotEmpty) { + PaddingValues(0.dp) + } else { + PaddingValues(bottom = FabPadding) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = if (emptyScreen != PrescriptionScreenData.EmptyActiveScreenState.NotEmpty) { + Arrangement.Center + } else { + Arrangement.Top + } ) { - when { - state.isDemoModeActive && state.prescriptions.isEmpty() -> { + when (emptyScreen) { + PrescriptionScreenData.EmptyActiveScreenState.LoggedIn -> { item { - SpacerXXLarge() - HomeNoHealthCard( - modifier = cardPaddingModifier, - onClickAction = { - navController.navigate(MainNavigationScreens.Camera.path()) - }, displayedScreen = displayedScreen - ) - } - } - - state.prescriptions.isEmpty() && state.ssoTokenSetAndConnected() -> { - item { - SpacerXXLarge() HomeHealthCardConnected( - modifier = cardPaddingModifier, - onClickAction = onClickRefresh, - displayedScreen = displayedScreen + modifier = Modifier.padding(PaddingDefaults.Medium), + onClickAction = onClickRefresh ) } } - state.prescriptions.isEmpty() && state.ssoTokenSetAndDisconnected() -> { + PrescriptionScreenData.EmptyActiveScreenState.LoggedOut -> { item { - SpacerXXLarge() HomeHealthCardDisconnected( - modifier = cardPaddingModifier, - onClickAction = onClickRefresh, - displayedScreen = displayedScreen + modifier = Modifier.padding(PaddingDefaults.Medium), + onClickAction = onClickRefresh ) } } - state.prescriptions.isEmpty() && state.noSsoTokenSet() -> { + PrescriptionScreenData.EmptyActiveScreenState.NeverConnected -> { item { - SpacerXXLarge() HomeNoHealthCard( - modifier = cardPaddingModifier, + modifier = Modifier.padding(PaddingDefaults.Medium), onClickAction = { - navController.navigate(MainNavigationScreens.Camera.path()) - }, displayedScreen = displayedScreen + showMlKitPermissionDialog = true + } ) } } - else -> { - displayedScreen(EmptyScreenState.ofValue(3)!!) - item { SpacerMedium() } - 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 - ) - - if (isFirstSyncedPrescription) { - Text( - (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization ?: "", - style = MaterialTheme.typography.h6, - modifier = Modifier - .fillMaxWidth() - .padding( - bottom = PaddingDefaults.Small, - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium - ) - ) - } else if (titleChanged) { - Text( - (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization ?: "", - style = MaterialTheme.typography.h6, - modifier = Modifier - .fillMaxWidth() - .then(headerPaddingModifier) - ) - } + PrescriptionScreenData.EmptyActiveScreenState.LoggedOutWithoutTokenBiometrics -> { + item { + HomeConnectedWithoutTokenBiometrics( + modifier = Modifier.padding(PaddingDefaults.Medium), + onClickAction = onClickRefresh + ) + } + } + PrescriptionScreenData.EmptyActiveScreenState.LoggedOutWithoutToken -> { + item { + HomeConnectedWithoutToken( + modifier = Modifier.padding(PaddingDefaults.Medium), + onClickAction = onClickRefresh + ) + } + } + PrescriptionScreenData.EmptyActiveScreenState.NotEmpty -> { + prescriptionContent( + state = state, + navController = navController + ) + } + } + } +} - when (prescription) { - is PrescriptionUseCaseData.Prescription.Synced -> - FullDetailMedication( - prescription, - state.nowInEpochDays, - modifier = cardPaddingModifier, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) - } +private fun LazyListScope.prescriptionContent( + navController: NavController, + state: PrescriptionScreenData.State +) { + item { SpacerXXLarge() } + state.prescriptions.forEachIndexed { index, prescription -> + item(key = "$index-${prescription.taskId}") { + val isFirstSyncedPrescription = + remember { index == 0 && prescription is PrescriptionUseCaseData.Prescription.Synced } + + val titleChanged = remember { + index > 0 && + (state.prescriptions[index - 1] as? PrescriptionUseCaseData.Prescription.Synced)?.organization != + (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization + } + + if (isFirstSyncedPrescription) { + Text( + (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization ?: "", + style = AppTheme.typography.h6, + modifier = Modifier + .fillMaxWidth() + .then(CardPaddingModifier) + ) + } else if (titleChanged) { + Text( + (prescription as? PrescriptionUseCaseData.Prescription.Synced)?.organization ?: "", + style = AppTheme.typography.h6, + modifier = Modifier + .fillMaxWidth() + .then(HeaderPaddingModifier) + ) + } + + when (prescription) { + is PrescriptionUseCaseData.Prescription.Synced -> + FullDetailMedication( + prescription, + modifier = CardPaddingModifier, + onClick = { + navController.navigate( + MainNavigationScreens.PrescriptionDetail.path( + taskId = prescription.taskId + ) ) + } + ) - is PrescriptionUseCaseData.Prescription.Scanned -> - LowDetailMedication( - modifier = cardPaddingModifier, - prescription, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) - } + is PrescriptionUseCaseData.Prescription.Scanned -> + LowDetailMedication( + modifier = CardPaddingModifier, + prescription, + onClick = { + navController.navigate( + MainNavigationScreens.PrescriptionDetail.path( + taskId = prescription.taskId + ) ) - } - } + } + ) } } } } @Composable -private fun ArchiveTabInformation( +private fun ArchivePrescriptionTab( state: PrescriptionScreenData.State, - navController: NavController, - cardPaddingModifier: Modifier, - headerPaddingModifier: Modifier, + listState: LazyListState, + navController: NavController ) { + val profileHandler = LocalProfileHandler.current + val emptyScreen = state.emptyArchiveScreen(profileHandler.activeProfile) + LazyColumn( modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally + state = listState, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = if (emptyScreen != PrescriptionScreenData.EmptyArchiveScreenState.NotEmpty) { + Arrangement.Center + } else { + Arrangement.Top + } ) { - when { - state.redeemedPrescriptions.isEmpty() && state.noSsoTokenSet() -> { + when (emptyScreen) { + PrescriptionScreenData.EmptyArchiveScreenState.NeverConnected -> { item { - SpacerXXLarge() - ArchiveNoEGKInitial( - modifier = cardPaddingModifier + ArchiveNoHealthCardInitial( + modifier = Modifier.padding(PaddingDefaults.Medium) ) } } - state.redeemedPrescriptions.isEmpty() -> { + PrescriptionScreenData.EmptyArchiveScreenState.NothingArchived -> { item { - SpacerXXLarge() - ArchiveNoEGKRedeemed( - modifier = cardPaddingModifier + ArchiveNoHealthCardRedeemed( + modifier = Modifier.padding(PaddingDefaults.Medium) ) } } else -> { - item { SpacerMedium() } - items(state.redeemedPrescriptions) { prescription -> - when (prescription) { - is PrescriptionUseCaseData.Prescription.Scanned -> - LowDetailMedication( - modifier = cardPaddingModifier, - prescription, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) + item { SpacerXXLarge() } + state.redeemedPrescriptions.forEachIndexed { index, prescription -> + item { + val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy") } + + val isFirstArchievedPrescription = + remember { index == 0 } + + val indexedPrescription = if (index > 0) { + state.redeemedPrescriptions[index - 1] + } else { + prescription + } + val instantOfArchivedPrescriptionIndexed by remember { + mutableStateOf( + indexedPrescription.redeemedOn ?: when (indexedPrescription) { + is PrescriptionUseCaseData.Prescription.Synced -> indexedPrescription.authoredOn + is PrescriptionUseCaseData.Prescription.Scanned -> indexedPrescription.scannedOn } ) - is PrescriptionUseCaseData.Prescription.Synced -> - FullDetailMedication( - prescription, - state.nowInEpochDays, - modifier = cardPaddingModifier, - onClick = { - navController.navigate( - MainNavigationScreens.PrescriptionDetail.path( - prescription.taskId - ) - ) + } + + val instantOfArchivedPrescription by remember { + mutableStateOf( + prescription.redeemedOn ?: when (prescription) { + is PrescriptionUseCaseData.Prescription.Synced -> prescription.authoredOn + is PrescriptionUseCaseData.Prescription.Scanned -> prescription.scannedOn } ) - } - } - } - } - } -} - -@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 - ) - Image( - Icons.Rounded.Close, - null, - modifier = Modifier.padding(start = 20.dp).clickable { onClose() }, - alpha = 0.3f - ) - } - } -} - -// 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 - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.Drag) { - performDrag(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } - - 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 - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - performFling(velocity = Offset(available.x, available.y).toFloat()) - return available - } + } - private fun Float.toOffset(): Offset = Offset(0f, this) + val yearChanged = remember { + index > 0 && + ( + instantOfArchivedPrescriptionIndexed.atZone(ZoneId.systemDefault()) + ?.toLocalDate()?.format(dateFormatter) != + instantOfArchivedPrescription.atZone(ZoneId.systemDefault()) + ?.toLocalDate()?.format(dateFormatter) + ) + } - private fun Offset.toFloat(): Float = this.y - } + if (isFirstArchievedPrescription) { + Text( + instantOfArchivedPrescription.atZone(ZoneId.systemDefault()) + ?.toLocalDate()?.format(dateFormatter).toString(), + style = AppTheme.typography.h6, + modifier = Modifier + .fillMaxWidth() + .then(CardPaddingModifier) + ) + } else if (yearChanged) { + Text( + instantOfArchivedPrescription.atZone(ZoneId.systemDefault()) + ?.toLocalDate()?.format(dateFormatter).toString(), + style = AppTheme.typography.h6, + modifier = Modifier + .fillMaxWidth() + .then(CardPaddingModifier) + ) + } -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun PullRefresh( - state: SwipeableState, - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { - val refreshDistance = with(LocalDensity.current) { 80.dp.toPx() } - - Box( - modifier = modifier - .testTag("pull2refresh") - .nestedScroll(state.preUpPostDownNestedScrollConnection(-refreshDistance)) - .swipeable( - state = state, - anchors = mapOf( - -refreshDistance to false, - refreshDistance to true - ), - orientation = Orientation.Vertical, - ) - .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) - ) + 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 + ) + ) + } + ) + } + } } } } @@ -662,17 +492,13 @@ private fun FullDetailRecipeCardPreview() { "", organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", name = "Pantoprazol 40 mg - Medikament mit sehr vielen Namensbestandteilen", - authoredOn = OffsetDateTime.now(), + authoredOn = Instant.now(), redeemedOn = null, - expiresOn = LocalDate.now().plusDays(21), - acceptUntil = LocalDate.now().plusDays(-1), - status = PrescriptionUseCaseData.Prescription.Synced.Status.InProgress, - isDirectAssignment = false, + expiresOn = Instant.now().plus(21, ChronoUnit.DAYS), + acceptUntil = Instant.now().minus(1, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.Other(SyncedTaskData.TaskStatus.InProgress), + isDirectAssignment = false ), - nowInEpochDays = Duration.between( - LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), - LocalDateTime.now() - ).toDays(), onClick = {} ) @@ -683,17 +509,13 @@ private fun FullDetailRecipeCardPreview() { organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", taskId = "", name = "Pantoprazol 40 mg", - authoredOn = OffsetDateTime.now(), + authoredOn = Instant.now(), redeemedOn = null, - expiresOn = LocalDate.now().plusDays(20), - acceptUntil = LocalDate.now().plusDays(97), - status = PrescriptionUseCaseData.Prescription.Synced.Status.Unknown, + expiresOn = Instant.now().plus(20, ChronoUnit.DAYS), + acceptUntil = Instant.now().plus(97, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.Other(SyncedTaskData.TaskStatus.Other), isDirectAssignment = false ), - nowInEpochDays = Duration.between( - LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), - LocalDateTime.now() - ).toDays(), onClick = {} ) @@ -704,17 +526,13 @@ private fun FullDetailRecipeCardPreview() { organization = "Medizinisches-Versorgungszentrum (MVZ) welches irgendeinen sehr langen Namen hat", taskId = "", name = "Pantoprazol 40 mg", - authoredOn = OffsetDateTime.now(), + authoredOn = Instant.now(), redeemedOn = null, - expiresOn = LocalDate.now(), - acceptUntil = LocalDate.now().plusDays(1), - status = PrescriptionUseCaseData.Prescription.Synced.Status.Completed, + expiresOn = Instant.now(), + acceptUntil = Instant.now().plus(1, ChronoUnit.DAYS), + state = SyncedTaskData.SyncedTask.Other(SyncedTaskData.TaskStatus.Completed), isDirectAssignment = false ), - nowInEpochDays = Duration.between( - LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), - LocalDateTime.now() - ).toDays(), onClick = {} ) } @@ -722,46 +540,86 @@ private fun FullDetailRecipeCardPreview() { @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) - } - expiryDaysLeft == 1L -> { - stringResource(id = R.string.prescription_item_expiration_only_today) - } - expiryDaysLeft <= 0L -> { - stringResource(id = R.string.prescription_item_expired) - } + state: SyncedTaskData.SyncedTask.TaskState, + now: LocalDate = LocalDate.now(), + nowDateTime: LocalDateTime = LocalDateTime.now() +): String = + when (state) { + is SyncedTaskData.SyncedTask.Other -> "" + is SyncedTaskData.SyncedTask.Ready -> { + val expiryDaysLeft = remember { + LocalDateTime.ofInstant(state.expiresOn, ZoneId.systemDefault()).toLocalDate() + .toEpochDay() - now.toEpochDay() + } + val acceptDaysLeft = remember { + LocalDateTime.ofInstant(state.acceptUntil, ZoneId.systemDefault()).toLocalDate() + .toEpochDay() - now.toEpochDay() + } + + when { + acceptDaysLeft == 0L -> { + stringResource(id = R.string.prescription_item_accept_only_today) + } + expiryDaysLeft == 1L -> { + stringResource(id = R.string.prescription_item_expiration_only_today) + } - else -> - if (acceptDaysLeft > 1L) { - annotatedPluralsResource( - R.plurals.prescription_item_accept_days, - acceptDaysLeft.toInt(), - AnnotatedString(acceptDaysLeft.toString()) + 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.InProgress -> { + val lastModified = remember { LocalDateTime.ofInstant(state.lastModified, ZoneId.systemDefault()) } + val dayDifference = remember { + LocalDateTime.ofInstant(state.lastModified, ZoneId.systemDefault()).toLocalDate() + .toEpochDay() - now.toEpochDay() + } + val minDifference = remember { Duration.between(lastModified, nowDateTime).toMinutes() } + when { + minDifference < 5L -> stringResource(R.string.sent_now) + minDifference < 60L -> annotatedStringResource( + R.string.sent_x_min_ago, + minDifference + ).toString() + dayDifference < 0L -> annotatedStringResource( + R.string.sent_on_day, + remember { dateString(lastModified) } ).toString() - } else { - annotatedPluralsResource( - R.plurals.prescription_item_expiration_days_new, - expiryDaysLeft.toInt(), - AnnotatedString(expiryDaysLeft.toString()) + else -> annotatedStringResource( + R.string.sent_on_minute, + remember { timeString(lastModified) } ).toString() } + } + is SyncedTaskData.SyncedTask.Pending -> { + val sentOn = remember { LocalDateTime.ofInstant(state.sentOn, ZoneId.systemDefault()) } + + annotatedStringResource( + R.string.sent_on_txt_code_description, + phrasedDateString(sentOn) + ).toString() + } + is SyncedTaskData.SyncedTask.Expired -> { + dateWithIntroductionString(R.string.pres_detail_medication_expired_on, state.expiredOn) + } } -} @OptIn(ExperimentalMaterialApi::class) @Composable private fun FullDetailMedication( prescription: PrescriptionUseCaseData.Prescription.Synced, - nowInEpochDays: Long, modifier: Modifier = Modifier, onClick: () -> Unit ) { @@ -774,41 +632,36 @@ private fun FullDetailMedication( ) { 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() + when (prescription.state) { + is SyncedTaskData.SyncedTask.Other -> { + when (prescription.state.state) { + SyncedTaskData.TaskStatus.Completed -> CompletedStatusChip() + else -> UnknownStatusChip() + } + } + is SyncedTaskData.SyncedTask.InProgress -> InProgressStatusChip() + is SyncedTaskData.SyncedTask.Pending -> PendingStatusChip() + is SyncedTaskData.SyncedTask.Ready -> ReadyStatusChip() + is SyncedTaskData.SyncedTask.Expired -> ExpiredStatusChip() } Spacer(Modifier.height(PaddingDefaults.Small + PaddingDefaults.Tiny)) Text( prescription.name, - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) val text = if (prescription.redeemedOn != null) { - val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") - prescription.redeemedOn.toInstant().atZone(ZoneId.systemDefault()) - .toLocalDate().format(dateFormatter) + dateWithIntroductionString( + R.string.pres_detail_medication_redeemed_on, + prescription.redeemedOn + ) } 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 - ) - } - } + expiryOrAcceptString(prescription.state) } } ?: "" @@ -819,7 +672,8 @@ private fun FullDetailMedication( } Icon( - Icons.Filled.KeyboardArrowRight, null, + Icons.Filled.KeyboardArrowRight, + null, tint = AppTheme.colors.neutral400, modifier = Modifier .size(24.dp) @@ -837,10 +691,10 @@ 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 = {} ) } } @@ -855,12 +709,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) } @@ -884,17 +738,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) 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 26414501..6ed5c3c9 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,180 +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.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.core.BaseViewModel -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.IDPConfigException -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(), - 0, - activeProfile = null - ) - - 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()) + init { + viewModelScope.launch { + while (true) { + delay(1000L * 60L) + timeTrigger.emit(Unit) + } } - - return combine( - demoUseCase.demoModeActive, - prescriptionFlow, - prescriptionUseCase.redeemedPrescriptions(), - settingsUseCase.settings, - profilesUseCase.profiles, - ) { demoActive, prescriptions, redeemed, settings, profiles -> - // TODO: split redeemed & unredeemed - PrescriptionScreenData.State( - isDemoModeActive = demoActive, - prescriptions = prescriptions, - redeemedPrescriptions = redeemed, - nowInEpochDays = LocalDate.now().toEpochDay(), - activeProfile = profiles.find { it.active } - ) - }.flowOn(dispatchProvider.unconfined()) } - 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 { - if (!isDemoModeActive) { - prescriptionUseCase.downloadCommunications(profileName).map { - downloadAllAuditEvents(profileName) - } - } - it - }.fold( - onSuccess = { - if (pullRefreshState != PullRefreshState.HasValidToken && !isDemoModeActive) { - onRefresh(RefreshEvent.NewPrescriptionsEvent(it)) - } - }, onFailure = { - (it.cause as? CancellationException)?.let { - return - } - - (it.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 - } - - (it.cause as? IDPConfigException)?.let { - // TODO propagate a more meaningful message - onRefresh(RefreshEvent.FatalTruststoreState) - return - } - - when (it.cause?.cause) { - is SocketTimeoutException, - is UnknownHostException -> { - onRefresh(RefreshEvent.NetworkNotAvailable) - return - } - } - - (it as? ApiCallException)?.let { - onRefresh( - RefreshEvent.ServerCommunicationFailedWhileRefreshing( - it.response.code() - ) - ) - return - } - - (it.cause as? VauException)?.let { - onRefresh(RefreshEvent.FatalTruststoreState) - return - } + @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) } - ) - } - - 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() + combine( + prescriptionFlow, + prescriptionUseCase.redeemedPrescriptions(activeProfile.id) + ) { prescriptions, redeemed -> + // TODO: split redeemed & unredeemed + PrescriptionScreenData.State( + prescriptions = prescriptions, + redeemedPrescriptions = redeemed + ) + } + }.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..e12f3722 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/ui/RefreshPrescriptionsController.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 org.kodein.di.compose.rememberViewModel +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(): RefreshPrescriptionsController { + val refreshPrescriptionUseCase by rememberInstance() + val authenticator = LocalAuthenticator.current + val mainScreenViewModel by rememberViewModel() + + 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 35edb144..8e6028a0 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,13 +121,12 @@ 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.LocalTracker +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 @@ -140,9 +138,7 @@ 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 @@ -152,7 +148,7 @@ import java.util.concurrent.Executors @Composable fun ScanScreen( mainNavController: NavController, - scanViewModel: ScanPrescriptionViewModel = hiltViewModel() + scanViewModel: ScanPrescriptionViewModel ) { val context = LocalContext.current @@ -206,7 +202,7 @@ fun ScanScreen( ) } - val tracker = LocalTracker.current + val tracker = LocalAnalytics.current ModalBottomSheetLayout( sheetState = sheetState, sheetContent = { @@ -313,7 +309,7 @@ private fun AccessDenied() { modifier = Modifier .fillMaxSize() .systemBarsPadding() - .testTag("camera/disallowed"), + .testTag("camera/disallowed") ) { TopBar( flashEnabled = false, @@ -326,18 +322,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() ) @@ -379,7 +376,7 @@ private fun HapticAndAudibleFeedback(scanVM: ScanPrescriptionViewModel = viewMod @Composable private fun EducationalDialog( - onContinue: () -> Unit, + onContinue: () -> Unit ) { var dialogOpen by remember { mutableStateOf(true) } @@ -410,13 +407,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())) + } } ) @@ -431,6 +428,7 @@ private fun beep(toneGenerator: ToneGenerator, pattern: ScanScreenData.Vibration ToneGenerator.TONE_PROP_NACK, 1000 ) + else -> {} } } @@ -498,7 +496,7 @@ private fun ActionBarButton( annotatedPluralsResource( R.plurals.cam_next_with, data.totalNrOfPrescriptions, - AnnotatedString(data.totalNrOfPrescriptions.toString()), + AnnotatedString(data.totalNrOfPrescriptions.toString()) ) ) } @@ -506,7 +504,7 @@ private fun ActionBarButton( @Composable private fun InfoCard( info: ScanScreenData.Info, - modifier: Modifier, + modifier: Modifier ) = Card( backgroundColor = Color.Black.copy(alpha = 0.6f), @@ -537,14 +535,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 ) } @@ -571,7 +569,7 @@ private fun InfoError(text: String) { Text( text, textAlign = TextAlign.Center, - style = MaterialTheme.typography.subtitle1 + style = AppTheme.typography.subtitle1 ) } } @@ -630,7 +628,10 @@ private fun CameraView( cameraProvider.unbindAll() camera = cameraProvider.bindToLifecycle( - lifecycleOwner, cameraSelector, preview, imageAnalysis + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis ) }, ContextCompat.getMainExecutor(context) @@ -677,7 +678,7 @@ private fun CameraView( @Composable private fun TopBar( flashEnabled: Boolean, - onFlashClick: (Boolean) -> Unit, + onFlashClick: (Boolean) -> Unit ) { val backPressDispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher @@ -693,7 +694,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)) @@ -705,7 +706,7 @@ private fun TopBar( checked = flashEnabled, onCheckedChange = onFlashClick, modifier = Modifier - .testId("camera/flashToggle") + .testTag("camera/flashToggle") .semantics { contentDescription = accTorch } ) { val ic = if (flashEnabled) { @@ -725,7 +726,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)) } @@ -783,7 +784,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..671b5382 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 @@ -21,13 +21,15 @@ package de.gematik.ti.erp.app.prescription.ui import androidx.compose.foundation.background 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.rounded.Check import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.EventBusy import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,7 +58,7 @@ fun StatusChip( .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() @@ -103,6 +105,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( @@ -123,10 +140,20 @@ 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 UnknownStatusChip() = StatusChip( text = stringResource(R.string.prescription_status_unknown), textColor = AppTheme.colors.neutral600, - backgroundColor = AppTheme.colors.neutral100, + backgroundColor = AppTheme.colors.neutral100 ) 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..fc1b95b0 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 } 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 ccdcae2b..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 @@ -20,27 +20,87 @@ package de.gematik.ti.erp.app.prescription.ui.model import androidx.compose.runtime.Immutable 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 isDemoModeActive: Boolean, val prescriptions: List, - val redeemedPrescriptions: List, - val nowInEpochDays: Long, - val activeProfile: ProfilesUseCaseData.Profile? + val redeemedPrescriptions: List ) { @Stable - fun noSsoTokenSet() = activeProfile?.ssoToken == null - - @Stable - fun ssoTokenSetAndConnected() = activeProfile?.ssoToken != null && activeProfile.ssoToken.isValid() + 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 ssoTokenSetAndDisconnected() = - (activeProfile?.ssoToken != null && !activeProfile.ssoToken.isValid()) || - activeProfile?.lastAuthenticated != null + 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 25761dfb..083fa08d 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 @@ -21,57 +21,57 @@ 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.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.DispatchProvider 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.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 kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject -import java.time.LocalDate -import java.time.OffsetDateTime +import io.github.aakira.napier.Napier +import java.time.Instant // 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(now: LocalDate = LocalDate.now()): Flow> = - syncedTasks().map { tasks -> - tasks.filter { it.isSyncedTaskRedeemable(now) } +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) } .sortedByDescending { it.authoredOn } - .groupBy { it.organization } + .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() ?: "", + organization = it.organizationName() ?: "", + authoredOn = it.authoredOn, redeemedOn = null, expiresOn = it.expiresOn, acceptUntil = it.acceptUntil, - status = mapStatus(it.status), + state = it.state(now = now), isDirectAssignment = it.taskId.startsWith(DIRECT_ASSIGNMENT_INDICATOR) ) } @@ -81,37 +81,40 @@ 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.isScannedTaskRedeemable() } - .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(now: LocalDate = LocalDate.now()): Flow> = + fun redeemedPrescriptions( + profileId: ProfileIdentifier, + now: Instant = Instant.now() + ): Flow> = combine( - scannedTasks(), - syncedTasks() + scannedTasks(profileId), + syncedTasks(profileId) ) { scannedTasks, syncedTasks -> val syncedPrescriptions = syncedTasks - .filter { it.isSyncedTaskRedeemed(now) } + .filter { !it.isActive(now) } .map { PrescriptionUseCaseData.Prescription.Synced( taskId = it.taskId, - name = it.medicationText ?: "", - organization = it.organization ?: "", + name = it.medicationRequest.medication?.text ?: "", + 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), + state = it.state(now), isDirectAssignment = it.taskId.startsWith(DIRECT_ASSIGNMENT_INDICATOR) ) } @@ -121,90 +124,117 @@ 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 }) + .sortedWith( + compareByDescending { + it.redeemedOn ?: when (it) { + is PrescriptionUseCaseData.Prescription.Scanned -> it.scannedOn + is PrescriptionUseCaseData.Prescription.Synced -> it.authoredOn + } + }.thenBy { it.taskId } + ) } - fun redeemableAndValidSyncedTaskIds(): Flow> = - syncedTasks().map { tasks -> - tasks.filter { it.isRedeemableAndValid() }.map { it.taskId } + suspend fun saveScannedTasks(profileId: ProfileIdentifier, tasks: List) = + withContext(dispatchers.IO) { + repository.saveScannedTasks(profileId, tasks) } - fun redeemableScannedTaskIds(): Flow> = - scannedTasks().map { tasks -> - tasks.filter { it.isScannedTaskRedeemable() }.map { it.taskId } + 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 + ) + } } - - private fun Task.isSyncedTaskRedeemable(now: LocalDate = LocalDate.now()): Boolean { - return expiresOn != null && expiresOn >= now && - (status == TaskStatus.Ready || status == TaskStatus.InProgress) - } - - private fun Task.isScannedTaskRedeemable(): Boolean = when (this.status) { - TaskStatus.Completed -> false - else -> this.redeemedOn == null + tasks.takeIf { it.isNotEmpty() }?.let { saveScannedTasks(profileId, it) } } - private fun Task.isRedeemableAndValid(): Boolean = isSyncedTaskRedeemable() && accessCode != null - - private fun Task.isSyncedTaskRedeemed(now: LocalDate): Boolean = !isSyncedTaskRedeemable(now) - - 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 + 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 { + val payload = createDataMatrixPayload(scannedTask.taskId, scannedTask.accessCode) + + emit( + UIPrescriptionDetailScanned( + profileId = scannedTask.profileId, + taskId = scannedTask.taskId, + redeemedOn = scannedTask.redeemedOn, + accessCode = scannedTask.accessCode, + matrixPayload = payload, + number = 1, + scannedOn = scannedTask.scannedOn + ) + ) + } + } + } else { + val payload = task.accessCode?.let { createDataMatrixPayload(task.taskId, it) } - suspend fun deletePrescription(taskId: String, isRemoteTask: Boolean): Result + emit( + UIPrescriptionDetailSynced( + profileId = task.profileId, + taskId = task.taskId, + redeemedOn = task.redeemedOn(), + accessCode = task.accessCode, + matrixPayload = payload, + expiresOn = task.expiresOn, + acceptUntil = task.acceptUntil, + patient = task.patient, + practitioner = task.practitioner, + insurance = task.insuranceInformation, + organization = task.organization, + medicationRequest = task.medicationRequest, + medicationDispenses = task.medicationDispenses, + taskStatus = task.status, + isRedeemableAndValid = task.redeemState().isRedeemable(), + state = task.state() // TODO pass now from calling function + ) + ) + } + }.flowOn(dispatchers.IO) - fun loadTasksForRedeemedOn( - redeemedOn: OffsetDateTime, - profileName: String - ): Flow> + suspend fun deletePrescription(profileId: ProfileIdentifier, taskId: String): Result { + return repository.deleteTaskByTaskId(profileId, taskId) + } - suspend fun saveLowDetailEvent(lowDetailEvent: LowDetailEventSimple) - suspend fun loadLowDetailEvents(taskId: String): Flow> - suspend fun deleteLowDetailEvents(taskId: String) + suspend fun redeemScannedTask(taskId: String, redeem: Boolean) { + repository.updateRedeemedOn(taskId, if (redeem) Instant.now() else null) + } - 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 getAllTasksWithTaskIdOnly(): Flow> = + repository.loadTaskIds() } fun createMatrixCode(payload: String): BitMatrix { 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 a9013991..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDelegate.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.prescription.usecase - -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 559db5d2..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseDemo.kt +++ /dev/null @@ -1,162 +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.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 { 0 } - } - - private fun demoRefreshResult(): Result { - return if (demoUseCase.authTokenReceived.value) { - prescriptionDemoDataSource.incrementRefresh() - Result.success(Unit) - } else { - Result.failure(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 df0ac338..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/PrescriptionUseCaseProduction.kt +++ /dev/null @@ -1,220 +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.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..d5e0f5d8 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/prescription/usecase/RefreshPrescriptionUseCase.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.prescription.usecase + +import de.gematik.ti.erp.app.DispatchProvider +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 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() + repository.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) } + + 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..1120332e 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,33 @@ 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. */ @Immutable data class Synced( override val taskId: String, + val state: SyncedTaskData.SyncedTask.TaskState, val name: String, val organization: String, - val authoredOn: OffsetDateTime, - override val redeemedOn: OffsetDateTime?, - val expiresOn: LocalDate?, - val acceptUntil: LocalDate?, - val status: Status, + val authoredOn: Instant, + override val redeemedOn: Instant?, + val expiresOn: Instant?, + val acceptUntil: Instant?, val isDirectAssignment: Boolean - ) : Prescription() { - enum class Status { - Ready, InProgress, Completed, Unknown - } - } + ) : Prescription() /** * Represents a single [Task] scanned by the user. @@ -55,8 +53,8 @@ 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() } } 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 8ccc1348..73c9a680 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 @@ -28,51 +28,69 @@ 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.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.res.stringResource import androidx.compose.ui.text.TextStyle -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 de.gematik.ti.erp.app.R +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.firstCharOfForeNameSurName @Composable fun Avatar( modifier: Modifier, - name: String, - profileColor: ProfileColor, + profile: ProfilesUseCaseData.Profile, ssoStatusColor: Color?, active: Boolean = false, - textStyle: TextStyle = MaterialTheme.typography.body2 + textStyle: TextStyle = AppTheme.typography.body2 ) { - val text = remember(name) { firstCharOfForeNameSurName(name) } - Box(modifier = modifier.fillMaxSize().aspectRatio(1f), contentAlignment = Alignment.Center) { - CircleBox( - profileColor.backGroundColor, - border = if (active) BorderStroke(2.dp, profileColor.borderColor) else null, - modifier = Modifier.fillMaxSize() - ) - Text( - text = text, - fontWeight = FontWeight.Bold, - style = textStyle, - color = profileColor.textColor, - textAlign = TextAlign.Center, - ) + val currentSelectedColors = profileColor(profileColorNames = profile.color) + + val initials = remember(profile.name) { firstCharOfForeNameSurName(profile.name) } + Box( + modifier = modifier + .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, + modifier = Modifier.fillMaxSize(), + figure = profile.avatarFigure, + initials = initials, + currentSelectedColors = currentSelectedColors, + textStyle = textStyle + ) + } + } if (ssoStatusColor != null) { CircleBox( backgroundColor = ssoStatusColor, border = BorderStroke(2.dp, MaterialTheme.colors.background), - modifier = Modifier.size(16.dp).align(Alignment.BottomEnd).offset(4.dp, 4.dp) + modifier = Modifier + .size(PaddingDefaults.Medium) + .align(Alignment.BottomEnd) + .offset(PaddingDefaults.Tiny, PaddingDefaults.Tiny) ) } } @@ -100,14 +118,21 @@ private fun CircleBox( private fun AvatarPreview() { AppTheme { Avatar( - modifier = Modifier.size(36.dp), name = "Ina Müller", - profileColor = ProfileColor( - textColor = AppTheme.colors.red700, - colorName = stringResource(R.string.profile_color_name_pink), - backGroundColor = AppTheme.colors.red200, - borderColor = AppTheme.colors.red400 + modifier = Modifier.size(36.dp), + profile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SUN_DEW, + avatarFigure = ProfilesData.AvatarFigure.Initials, + personalizedImage = null, + lastAuthenticated = null, + ssoTokenScope = null ), - ssoStatusColor = null, active = false + ssoStatusColor = null, + active = false, + textStyle = AppTheme.typography.body2 ) } } @@ -117,14 +142,20 @@ private fun AvatarPreview() { private fun AvatarWithSSOPreview() { AppTheme { Avatar( - modifier = Modifier.size(36.dp), name = "Ina Müller", - profileColor = ProfileColor( - textColor = AppTheme.colors.red700, - colorName = stringResource(R.string.profile_color_name_pink), - backGroundColor = AppTheme.colors.red200, - borderColor = AppTheme.colors.red400 + modifier = Modifier.size(36.dp), + profile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SUN_DEW, + avatarFigure = ProfilesData.AvatarFigure.Initials, + personalizedImage = null, + lastAuthenticated = null, + ssoTokenScope = null ), - ssoStatusColor = Color.Green, active = false + ssoStatusColor = Color.Green, + active = false ) } } 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..43a6950c 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, + ProfileDestinations.ProfileImageCropper.arguments + ) { + 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 d6162b10..ce83c434 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,18 @@ 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.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.ColumnScope 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 @@ -34,55 +38,71 @@ 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.Image import androidx.compose.material.icons.outlined.VpnKey -import androidx.compose.material.icons.rounded.Devices -import androidx.compose.material.icons.rounded.LockOpen +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.LocalUriHandler 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.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight - +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 de.gematik.ti.erp.app.R -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.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.cardwall.ui.PrimaryButtonSmall +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.SettingsScreen @@ -91,33 +111,28 @@ 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.InputField -import de.gematik.ti.erp.app.utils.compose.LabeledSwitch -import de.gematik.ti.erp.app.utils.compose.LabeledSwitchWithLink -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.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.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.annotatedStringResource +import de.gematik.ti.erp.app.utils.compose.visualTestTag import de.gematik.ti.erp.app.utils.firstCharOfForeNameSurName import de.gematik.ti.erp.app.utils.sanitizeProfileName -import kotlinx.coroutines.delay -import java.util.Locale +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() @@ -125,8 +140,9 @@ fun EditProfileScreen( state = state, navController = navController, onBack = onBack, - profile = profile, + selectedProfile = profile, settingsViewModel = settingsViewModel, + profileSettingsViewModel = profileSettingsViewModel, onRemoveProfile = onRemoveProfile, mainNavController = mainNavController ) @@ -134,10 +150,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 { @@ -146,11 +163,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() @@ -160,81 +181,136 @@ fun EditProfileScreen( } } +@Suppress("LongMethod") @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() - val isFeatureBioLogin by produceState(false) { - settingsViewModel.isFeatureBioLoginEnabled().collect { value = it } + var showAddDefaultProfileDialog by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + var deleteProfileDialogVisible by remember { mutableStateOf(false) } + + if (deleteProfileDialogVisible) { + 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 = { 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, - ) { - var showAddDefaultProfileDialog by remember { mutableStateOf(false) } + actions = { + 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) + } + ) + } + DropdownMenuItem( + modifier = Modifier.testTag(TestTag.Profile.DeleteProfileButton), + onClick = { + expanded = false + deleteProfileDialogVisible = true + } + ) { + Text( + text = stringResource(R.string.remove_profile), + color = AppTheme.colors.red600 + ) + } + } + }, + onBack = onBack + ) { 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, it) } ) } - if (isFeatureBioLogin) item { LoginSection() } - 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) { @@ -248,94 +324,16 @@ 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 - ) - }, - 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, - ) - } - ) { - } -} - @Composable fun SecuritySection( onClickToken: () -> Unit, - onClickAuditEvents: () -> Unit, - tokenAvailable: Boolean + onClickAuditEvents: () -> Unit ) { - MenuHeadline(stringResource(R.string.settings_appprotection_headline)) - SecurityTokenSubSection(tokenAvailable, onClickToken) + SettingsMenuHeadline(stringResource(R.string.settings_appprotection_headline)) + SecurityTokenSubSection(onClickToken) SecurityAuditEventsSubSection(onClickAuditEvents) } -@Composable -fun LoginSection() { - val context = LocalContext.current - val deviceBioStatus = deviceStrongBiometricStatus(context) - val isStrongBoxAndBiometric = isDeviceSupportsBiometric(deviceBioStatus) && hasDeviceStrongBox(context) - val checked = false // todo: ERA-4389 - Check Saved Bio-Strong data on repo - val onCheckedChange: (Boolean) -> Unit = {} // todo: ERA-4389 - toggleOn = open Cardwall for save bioStrong data on IDP; toggleOff = ERA-4388 - val onConnectedDevicesClicked: () -> Unit = {} // todo: ERA-4389 - Geräte mit gemerkten Zugangsdaten anzeigen - val uriHandler = LocalUriHandler.current - val uri = stringResource(R.string.settings_faq_link) - - MenuHeadline(stringResource(R.string.settings_login_headline)) - if (isStrongBoxAndBiometric) { - LabeledSwitch( - enabled = isStrongBoxAndBiometric, - checked = checked, - onCheckedChange = onCheckedChange, - icon = Icons.Rounded.LockOpen, - header = stringResource(R.string.settings_login_data_save), - description = null, - ) - } else { - LabeledSwitchWithLink( - enabled = isStrongBoxAndBiometric, - checked = checked, - onCheckedChange = onCheckedChange, - icon = Icons.Rounded.LockOpen, - header = stringResource(R.string.settings_login_data_save), - description = stringResource(R.string.settings_login_no_bio_and_strongbox_device), - link = stringResource(R.string.settings_faq), - onClickLink = { uriHandler.openUri(uri) } - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top, - modifier = Modifier - .fillMaxWidth() - .clickable( - onClick = onConnectedDevicesClicked - ) - .padding(PaddingDefaults.Medium) - .semantics(mergeDescendants = true) {}, - ) { - Icon(Icons.Rounded.Devices, null, tint = AppTheme.colors.primary600) - Text(stringResource(R.string.settings_login_connected_devices), style = MaterialTheme.typography.body1) - } -} - @Composable fun SecurityAuditEventsSubSection(onClickAuditEvents: () -> Unit) { Row( @@ -349,15 +347,15 @@ fun SecurityAuditEventsSubSection(onClickAuditEvents: () -> Unit) { } ) .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( @@ -370,47 +368,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( @@ -423,106 +400,172 @@ fun SecurityTokenSubSection(tokenAvailable: Boolean, onClick: () -> Unit) { } @Composable -private fun MenuHeadline(text: String) { +fun SettingsMenuHeadline(text: String) { Text( text = text, - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(PaddingDefaults.Medium), + style = AppTheme.typography.h6, + modifier = Modifier.padding(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 - ) +private fun ColumnScope.LoginButton( + onClick: () -> Unit, + buttonText: String +) { + PrimaryButtonSmall( + modifier = Modifier.align(Alignment.CenterHorizontally).testTag(TestTag.Profile.LoginButton), + onClick = onClick + ) { + Text(buttonText) + } } +@OptIn(ExperimentalComposeUiApi::class) @Composable -private fun LogoutButton(onClick: () -> Unit) { - var dialogVisible by remember { mutableStateOf(false) } +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) } - 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 - } - ) + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(textFieldEnabled) { + if (textFieldEnabled) { + focusRequester.requestFocus() + keyboardController?.show() + } } - LoginLogoutButton( - onClick = { dialogVisible = true }, - buttonText = R.string.logout_profile, - buttonDescription = R.string.logout_description, - contentColor = AppTheme.colors.red700 - ) + Column { + 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 + } + ) + } else { + ProfileEditBasicTextField( + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + 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 = errorText, + color = AppTheme.colors.red600, + style = AppTheme.typography.caption1, + modifier = Modifier.padding(start = PaddingDefaults.Medium) + ) + } + } } @Composable -private fun LoginLogoutButton( - onClick: () -> Unit, - @StringRes buttonText: Int, - @StringRes buttonDescription: Int, - contentColor: Color +private fun ProfileEditBasicTextField( + modifier: Modifier, + enabled: Boolean, + initialProfileName: String, + onChangeProfileName: (String, Boolean) -> Unit, + state: SettingsScreen.State, + onDone: () -> Unit ) { - Button( - onClick = { onClick() }, - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp, - top = 32.dp, - bottom = 16.dp - ) - .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 + var profileNameState by remember { + mutableStateOf( + TextFieldValue( + text = initialProfileName, + selection = TextRange(initialProfileName.length) ) ) } - Text( - stringResource(buttonDescription), - modifier = Modifier.padding( - start = PaddingDefaults.Medium, - end = PaddingDefaults.Medium, - bottom = PaddingDefaults.Small + + BasicTextField( + value = profileNameState, + onValueChange = { + val name = sanitizeProfileName(it.text.trimStart()) + profileNameState = TextFieldValue( + text = name, + selection = it.selection, + composition = it.composition + ) + + onChangeProfileName( + name, + name.trim() == initialProfileName || !state.containsProfileWithName(name) && name.isNotEmpty() + ) + }, + enabled = enabled, + singleLine = false, + textStyle = AppTheme.typography.h5, + modifier = modifier, + 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( @@ -539,146 +582,172 @@ 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( + modifier = Modifier.fillMaxSize(), + profile = profile, + figure = profile.avatarFigure, + initials = initials, + currentSelectedColors = currentSelectedColors ) } } - - LaunchedEffect(profileName) { - if (!profileNameError) { - delay(500) - onChangeProfileName(profileName) - } + SpacerSmall() + TextButton(modifier = Modifier.align(Alignment.CenterHorizontally), onClick = onClickEditAvatar) { + Text(text = stringResource(R.string.edit_profile_avatar), textAlign = TextAlign.Center) } + } +} - val keyboardController = LocalSoftwareKeyboardController.current - - Spacer40() - InputField( - modifier = Modifier - .testTag("editProfile/profile_text_input") - .fillMaxWidth(), - value = profileName, - onValueChange = { - profileName = sanitizeProfileName(it.trimStart()) - profileNameError = profileName.isEmpty() || - ( - profileName.trim() != profile.name && state.containsProfileWithName( - profileName - ) - ) - }, - onSubmit = { - if (!profileNameError) { - onChangeProfileName(profileName) - keyboardController?.hide() - } - }, - isError = profileNameError, - ) +@Composable +fun ChooseAvatar( + profile: ProfilesUseCaseData.Profile, + modifier: Modifier = Modifier, + showPersonalizedImage: Boolean = true, + figure: ProfilesData.AvatarFigure, + initials: String, + currentSelectedColors: ProfileColor, + textStyle: TextStyle = AppTheme.typography.h3 +) { + val imageRessource = 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) + when (figure) { + ProfilesData.AvatarFigure.PersonalizedImage -> { + if (profile.personalizedImage != null && showPersonalizedImage) { + BitmapImage(profile) + } else { + Icon(Icons.Outlined.Image, null, modifier = Modifier.size(40.dp)) + } } - - if (profileNameError) { - Spacer4() + ProfilesData.AvatarFigure.Initials -> { Text( - text = errorText, - color = AppTheme.colors.red600, - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(start = PaddingDefaults.Medium) + text = initials, + style = textStyle, + color = if (showPersonalizedImage) { + currentSelectedColors.textColor + } else { + AppTheme.colors.neutral600 + }, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold ) } - SpacerMedium() - ProfileConnectedCard(profile.insuranceInformation) - Spacer40() - Text( - stringResource(R.string.edit_profile_background_color), - style = MaterialTheme.typography.h6 - ) - - 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 - ) - } - } - Spacer16() - Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { - Text( - currentSelectedColors.colorName, - style = AppTheme.typography.body2l + else -> { + Image( + painterResource(id = imageRessource), + null, + modifier ) } } } @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 +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 (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 + 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(modifier = Modifier.padding(horizontal = PaddingDefaults.Medium)) { + Text(stringResource(id = R.string.insurance_information_header), style = AppTheme.typography.h6) + SpacerMedium() + + if (lastAuthenticated != null) { + Column { + LabeledText( + stringResource(R.string.insurance_information_insurant_name), + insuranceInformation.insurantName + ) + SpacerMedium() + LabeledText( + stringResource(R.string.insurance_information_insurance_name), + insuranceInformation.insuranceName + ) + SpacerMedium() + cardAccessNumber?.let { + LabeledText(stringResource(R.string.insurance_information_insurant_can), it) + SpacerMedium() + } + + LabeledText( + stringResource(R.string.insurance_information_insurance_identifier), + insuranceInformation.insuranceIdentifier, + Modifier.testTag(TestTag.Profile.InsuranceId) ) - Text(insuranceInformation.insurantName, style = textStyle) - Text(insuranceInformation.insuranceIdentifier, style = textStyle) - Text(insuranceInformation.insuranceName, style = textStyle) + SpacerMedium() } } else { - Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { - Text( - stringResource(R.string.profile_not_connected), - textAlign = TextAlign.Center, - style = textStyle - ) + Column( + modifier = Modifier + .fillMaxWidth() + .border(width = 1.dp, color = AppTheme.colors.neutral300, shape = RoundedCornerShape(size = 16.dp)) + .padding(PaddingDefaults.Medium) + ) { + Text(stringResource(R.string.insurance_information_login_description)) + SpacerMedium() + LoginButton(onClickLogIn, stringResource(R.string.insurance_information_login)) } } + SpacerLarge() + Divider() + SpacerLarge() } } @Composable -fun createProfileColor(colors: ProfileColorNames): ProfileColor { +fun createProfileColor(colors: ProfilesData.ProfileColorNames): ProfileColor { return profileColor(profileColorNames = colors) } @Composable fun ColorSelector( - profileColorName: ProfileColorNames, + profileColorName: ProfilesData.ProfileColorNames, selected: Boolean, - onSelectColor: (ProfileColorNames) -> Unit, + onSelectColor: (ProfilesData.ProfileColorNames) -> Unit ) { val colors = createProfileColor(profileColorName) val contentDescription = annotatedStringResource( @@ -709,47 +778,13 @@ 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 - } - ) - } - - 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.remove_profile).uppercase(Locale.getDefault()), - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 8.dp - ) - ) +fun LabeledText(description: String, content: String, modifier: Modifier = Modifier) { + Column(modifier) { + Text(content, style = AppTheme.typography.body1) + Text(description, style = AppTheme.typography.body2l) } } 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..26d19772 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/profiles/ui/ProfileColorAndImagePicker.kt @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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.runtime.getValue +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.res.stringResource +import androidx.compose.ui.unit.dp + +import androidx.compose.foundation.layout.imePadding +import de.gematik.ti.erp.app.R +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 +import de.gematik.ti.erp.app.utils.firstCharOfForeNameSurName + +const val X_OFFSET = (-8) +const val Y_OFFSET = 4 + +@Composable +fun ProfileColorAndImagePicker( + selectedProfile: ProfilesUseCaseData.Profile, + clearPersonalizedImage: () -> Unit, + onPickPersonalizedImage: () -> Unit, + onBack: () -> Unit, + onSelectAvatar: (ProfilesData.AvatarFigure) -> Unit, + onSelectProfileColor: (ProfilesData.ProfileColorNames) -> Unit +) { + val initials = remember(selectedProfile.name) { firstCharOfForeNameSurName(selectedProfile.name) } + + val listState = rememberLazyListState() + + val currentSelectedColors = profileColor(profileColorNames = selectedProfile.color) + + 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() + ProfileImagePreview(selectedProfile) { + clearPersonalizedImage() + } + } + item { + SpacerXXLarge() + AvatarPicker( + profile = selectedProfile, + initials = initials, + currentSelectedColor = currentSelectedColors, + 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, + initials: String, + currentSelectedColor: ProfileColor +) { + val listState = rememberLazyListState() + LazyRow( + state = listState, + horizontalArrangement = Arrangement.spacedBy(PaddingDefaults.Medium) + ) { + ProfilesData.AvatarFigure.values().forEach { figure -> + item { + AvatarSelector( + figure = figure, + initials = initials, + currentSelectedColor = currentSelectedColor, + 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, + initials: String, + currentSelectedColor: ProfileColor +) { + 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( + modifier = Modifier.fillMaxSize(), + showPersonalizedImage = false, + profile = profile, + figure = figure, + initials = initials, + currentSelectedColors = currentSelectedColor, + textStyle = AppTheme.typography.h6 + ) + } + } +} + +@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( + profileColorName = it, + selected = currentValueColors == currentSelectedColors, + onSelectColor = onSelectProfileColor + ) + } + } + SpacerMedium() + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text( + currentSelectedColors.colorName, + style = AppTheme.typography.body2l + ) + } + } +} + +@Composable +fun ProfileImagePreview(selectedProfile: ProfilesUseCaseData.Profile, onClickDeleteAvatar: () -> Unit) { + val colors = profileColor(profileColorNames = selectedProfile.color) + val initials = remember(selectedProfile.name) { firstCharOfForeNameSurName(selectedProfile.name) } + val currentSelectedColors = profileColor(profileColorNames = selectedProfile.color) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PaddingDefaults.Medium) + ) { + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { + Box( + modifier = Modifier + .size(160.dp) + .clip(CircleShape) + .aspectRatio(1f) + .background(colors.backGroundColor), + contentAlignment = Alignment.Center + ) { + ChooseAvatar( + modifier = Modifier.fillMaxSize(), + profile = selectedProfile, + figure = selectedProfile.avatarFigure, + initials = initials, + currentSelectedColors = currentSelectedColors + ) + } + if (selectedProfile.avatarFigure != ProfilesData.AvatarFigure.Initials) { + Box( + modifier = Modifier + .size(32.dp) + .align(Alignment.TopEnd) + .offset(X_OFFSET.dp, Y_OFFSET.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..0bfb9e6b --- /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) + } +} + +private val DefaultProfile = ProfilesUseCaseData.Profile( + id = "", + name = "", + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), + active = false, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + lastAuthenticated = null, + ssoTokenScope = null, + avatarFigure = ProfilesData.AvatarFigure.Initials +) + +@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..ec4110fc --- /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(profile: ProfilesUseCaseData.Profile, newName: String) { + viewModelScope.launch { + profilesUseCase.updateProfileName(profile, 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..5f7e1dbf 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) - } + sanitizedProfileName(newProfileName)?.also { profileName -> + profilesRepository.updateProfileName(profile.id, 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..faed1623 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,91 @@ 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 + + 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..746883ba 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.redeemState().isRedeemable() && 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.isRedeemable() && 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/AllowAnalyticsScreen.kt b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowAnalyticsScreen.kt index c1528a89..f9ca8328 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 @@ -18,36 +18,42 @@ package de.gematik.ti.erp.app.settings.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +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.rememberScrollState -import androidx.compose.foundation.verticalScroll -import de.gematik.ti.erp.app.utils.compose.BottomAppBar +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.AppBarDefaults +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults 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.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +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.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 +61,93 @@ 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( + 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) - } + Surface( + Modifier.fillMaxWidth().wrapContentHeight(), + color = MaterialTheme.colors.surface, + elevation = AppBarDefaults.BottomAppBarElevation + ) { + Row( + Modifier.fillMaxWidth().wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center ) { - Text(stringResource(R.string.settings_tracking_not_allow).uppercase(Locale.getDefault())) - } - Spacer(modifier = Modifier.weight(1f)) - TextButton( - onClick = { - onAllowAnalytics(true) - createToastShort(context, allowText) + Button( + modifier = Modifier.padding(12.dp), + shape = RoundedCornerShape(8.dp), + enabled = true, + colors = ButtonDefaults.buttonColors(), + contentPadding = PaddingValues( + horizontal = PaddingDefaults.XXLarge + PaddingDefaults.Small, + vertical = PaddingDefaults.ShortMedium + ), + onClick = { + onAllowAnalytics(true) + createToastShort(context, allowText) + onBack() + } + ) { + Text(stringResource(R.string.settings_tracking_allow_button)) } - ) { - Text(stringResource(R.string.settings_tracking_allow).uppercase(Locale.getDefault())) } - Spacer24() } } ) { - 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()) ) { - 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..7d127b98 --- /dev/null +++ b/android/src/main/java/de/gematik/ti/erp/app/settings/ui/AllowBiometryScreen.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.Arrangement +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.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +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.MaterialTheme +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.Alignment +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.imePadding +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 +import de.gematik.ti.erp.app.utils.compose.BottomAppBar + +@Composable +fun AllowBiometryScreen( + onBack: () -> Unit, + onNext: () -> Unit, + onSecureMethodChange: (OnboardingSecureAppMethod) -> Unit +) { + var showBiometricPrompt by remember { mutableStateOf(false) } + val lazyListState = rememberLazyListState() + + AnimatedElevationScaffold( + modifier = Modifier.imePadding(), + navigationMode = NavigationBarMode.Close, + bottomBar = { + BottomAppBar(backgroundColor = MaterialTheme.colors.surface) { + Row( + Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Button( + shape = RoundedCornerShape(8.dp), + enabled = true, + colors = ButtonDefaults.buttonColors(), + contentPadding = PaddingValues( + horizontal = PaddingDefaults.XXLarge + PaddingDefaults.Small, + vertical = PaddingDefaults.ShortMedium + ), + onClick = { + showBiometricPrompt = true + } + ) { + Text(stringResource(R.string.settings_device_security_allow)) + } + } + } + }, + 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 3239e6c9..230b786c 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,12 +19,16 @@ 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 @@ -34,14 +38,14 @@ 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.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 @@ -50,24 +54,22 @@ 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( + listState = listState, + topBarTitle = header, + onBack = onBack, + navigationMode = NavigationBarMode.Back ) { innerPadding -> val infoText = if (lastAuthenticated == null) { @@ -76,34 +78,34 @@ 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), + style = AppTheme.typography.subtitle1 + ) + SpacerSmall() + Text( + infoText, + style = AppTheme.typography.body2l, + 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( @@ -126,7 +128,7 @@ fun AuditEventsScreen( id = R.string.audit_events_updated_at, phrasedDateString(date = lastAuthenticatedDate) ), - style = AppTheme.typography.captionl, + style = AppTheme.typography.caption1l, textAlign = TextAlign.Center ) } @@ -136,16 +138,17 @@ fun AuditEventsScreen( itemsIndexed(pagingItems) { _, auditEvent -> auditEvent?.let { Column(modifier = Modifier.padding(PaddingDefaults.Medium)) { - if (auditEvent.medicationText != null) { + 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/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 03848579..ca935fa8 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 @@ -38,10 +38,9 @@ import androidx.compose.foundation.verticalScroll 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 @@ -51,7 +50,7 @@ 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 @@ -70,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 @@ -82,9 +81,9 @@ 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 @@ -100,13 +99,14 @@ fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewM var passwordScore by remember { mutableStateOf(0) } val focusRequester = FocusRequester.Default val coroutineScope = rememberCoroutineScope() - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Back, - title = stringResource(R.string.settings_password_headline), - ) { navController.popBackStack() } - }, + val scrollState = rememberScrollState() + + 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)) @@ -135,7 +135,7 @@ fun SecureAppWithPassword(navController: NavController, viewModel: SettingsViewM modifier = Modifier .fillMaxSize() .padding(innerPadding) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(PaddingDefaults.Medium) ) { PasswordTextField( @@ -148,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() } ) @@ -227,19 +227,26 @@ 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 && isConsistent) { - onSubmit() - } + onSubmit() }, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { @@ -249,9 +256,8 @@ fun PasswordTextField( stringResource(R.string.consistent_password) ) } else if (allowVisiblePassword) { - IconToggleButton( - checked = passwordVisible, - onCheckedChange = { passwordVisible = it } + IconButton( + onClick = { passwordVisible = !passwordVisible } ) { when (passwordVisible) { true -> Icon( @@ -273,7 +279,6 @@ fun PasswordTextField( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun ConfirmationPasswordTextField( modifier: Modifier, @@ -283,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, @@ -295,7 +304,11 @@ fun ConfirmationPasswordTextField( onValueChange = onValueChange, isConsistent = isConsistent, isError = isError, - onSubmit = onSubmit, + onSubmit = { + if (!isError && isConsistent) { + onSubmit() + } + }, allowAutofill = true, allowVisiblePassword = true, label = { @@ -323,6 +336,8 @@ fun ConfirmationPasswordTextField( ) } +// tag::PasswordStrength[] + @Composable fun PasswordStrength( modifier: Modifier, @@ -350,33 +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, - modifier = Modifier.padding(start = PaddingDefaults.Medium) - ) + ), + style = AppTheme.typography.caption1l + ) + } SpacerMedium() Box( @@ -412,7 +426,9 @@ fun PasswordStrength( } } -private fun checkPasswordScore(score: Int): Boolean = +// end::PasswordStrength[] + +fun checkPasswordScore(score: Int): Boolean = score > minimalPasswordScore fun checkPassword(password: String, repeatedPassword: String, score: Int): Boolean = 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 index f653eeb9..f8557849 100644 --- 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 @@ -22,8 +22,6 @@ 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.MaterialTheme -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -33,22 +31,22 @@ 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.NavigationTopAppBar -import de.gematik.ti.erp.app.utils.compose.Spacer16 +import de.gematik.ti.erp.app.utils.compose.SpacerMedium import de.gematik.ti.erp.app.utils.compose.annotatedStringResource @Composable fun PharmacyLicenseScreen(onClose: () -> Unit) { - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - title = stringResource(R.string.settings_licence_pharmacy_search), - ) { onClose() } - } + 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 @@ -58,15 +56,15 @@ fun PharmacyLicenseScreen(onClose: () -> Unit) { top = PaddingDefaults.Medium, bottom = (PaddingDefaults.XLarge * 2) ) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) ) { Text( stringResource(R.string.license_pharmacy_search_description), - style = MaterialTheme.typography.body1, - color = AppTheme.colors.neutral999, + style = AppTheme.typography.body1, + color = AppTheme.colors.neutral999 ) - Spacer16() + SpacerMedium() val link = provideLinkForString( @@ -80,7 +78,7 @@ fun PharmacyLicenseScreen(onClose: () -> Unit) { ClickableTaggedText( annotatedStringResource(R.string.license_pharmacy_search_web_link_info, link), - style = MaterialTheme.typography.body1, + style = AppTheme.typography.body1, onClick = { range -> uriHandler.openUri(range.item) } 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 cc0028a7..94042645 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,7 +18,6 @@ package de.gematik.ti.erp.app.settings.ui -import TokenScreen import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState @@ -33,14 +32,15 @@ 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.cardunlock.ui.UnlockEgKScreen import de.gematik.ti.erp.app.debug.ui.DebugScreenWrapper +import de.gematik.ti.erp.app.license.ui.LicenseScreen 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.profiles.ui.ProfileSettingsViewModel 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 @@ -52,38 +52,40 @@ object SettingsNavigationScreens { object OpenSourceLicences : Route("OpenSourceLicences") object AdditionalLicences : Route("AdditionalLicences") 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) + Route("EditProfile", navArgument("profileId") { type = NavType.StringType }) { + fun path(profileId: String) = path("profileId" to profileId) + } + object UnlockEgk : Route("UnlockEgk", navArgument("changeSecret") { type = NavType.BoolType }) { + fun path(changeSecret: Boolean) = path("changeSecret" to changeSecret) } } enum class SettingsScrollTo { None, Authentication, - DemoMode, - Profiles + Profiles, + HealthCard } +@Suppress("LongMethod") @Composable fun SettingsNavGraph( settingsNavController: NavHostController, navigationMode: NavigationMode, scrollTo: SettingsScrollTo, mainNavController: NavController, - settingsViewModel: SettingsViewModel + settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel ) { val state by produceState(SettingsScreen.defaultState) { settingsViewModel.screenState().collect { value = it } } - NavHost( settingsNavController, startDestination = SettingsNavigationScreens.Settings.path() @@ -99,9 +101,7 @@ fun SettingsNavGraph( } } composable(SettingsNavigationScreens.Debug.route) { - NavigationAnimation(mode = navigationMode) { - DebugScreenWrapper(settingsNavController) - } + DebugScreenWrapper(settingsNavController) } composable(SettingsNavigationScreens.Terms.route) { NavigationAnimation(mode = navigationMode) { @@ -130,10 +130,8 @@ fun SettingsNavGraph( } composable(SettingsNavigationScreens.OpenSourceLicences.route) { NavigationAnimation(mode = navigationMode) { - WebViewScreen( - title = stringResource(R.string.settings_legal_licences), - onBack = { settingsNavController.popBackStack() }, - url = URI_LICENCES + LicenseScreen( + onBack = { settingsNavController.popBackStack() } ) } } @@ -146,20 +144,15 @@ fun SettingsNavGraph( } composable(SettingsNavigationScreens.AllowAnalytics.route) { NavigationAnimation(mode = navigationMode) { - AllowAnalyticsScreen { - if (it) { - settingsViewModel.onTrackingAllowed() - } else { - settingsViewModel.onTrackingDisallowed() + AllowAnalyticsScreen( + onBack = { settingsNavController.popBackStack() }, + onAllowAnalytics = { + if (it) { + settingsViewModel.onTrackingAllowed() + } else { + settingsViewModel.onTrackingDisallowed() + } } - settingsNavController.popBackStack() - } - } - } - composable(SettingsNavigationScreens.FeedbackForm.route) { - NavigationAnimation(mode = navigationMode) { - FeedbackForm( - settingsNavController ) } } @@ -174,28 +167,18 @@ fun SettingsNavGraph( 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, + SettingsNavigationScreens.EditProfile.arguments ) { - val profileId = - remember { settingsNavController.currentBackStackEntry!!.arguments!!.getInt("profileId") } + val profileId = remember { it.arguments!!.getString("profileId")!! } state.profileById(profileId)?.let { profile -> EditProfileScreen( state, profile, settingsViewModel, + profileSettingsViewModel, onRemoveProfile = { settingsViewModel.removeProfile(profile, it) settingsNavController.popBackStack() @@ -205,5 +188,25 @@ fun SettingsNavGraph( ) } } + composable( + SettingsNavigationScreens.UnlockEgk.route, + SettingsNavigationScreens.UnlockEgk.arguments + ) { + val changeSecret = remember { + it.arguments!!.getBoolean("changeSecret") + } + + NavigationAnimation(mode = navigationMode) { + UnlockEgKScreen( + changeSecret = changeSecret, + navController = settingsNavController, + onClickLearnMore = { + settingsNavController.navigate( + SettingsNavigationScreens.OrderHealthCard.path() + ) + } + ) + } + } } } 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 dbb49d9d..1429ba74 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,39 +18,37 @@ package de.gematik.ti.erp.app.settings.ui -import androidx.biometric.BiometricManager +import android.content.Context +import android.os.Build 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 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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues 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.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 @@ -58,6 +56,7 @@ import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Fingerprint import androidx.compose.material.icons.outlined.Info +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 @@ -87,65 +86,63 @@ 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.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.profiles.ui.Avatar +import de.gematik.ti.erp.app.profiles.ui.ProfileSettingsViewModel 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.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.AcceptDialog 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.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.OutlinedDebugButton -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.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 java.util.Locale import de.gematik.ti.erp.app.utils.dateTimeShortText -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import java.util.Locale @Composable fun SettingsScreen( scrollTo: SettingsScrollTo, mainNavController: NavController, - settingsViewModel: SettingsViewModel = hiltViewModel() + settingsViewModel: SettingsViewModel, + profileSettingsViewModel: ProfileSettingsViewModel ) { val settingsNavController = rememberNavController() @@ -165,7 +162,8 @@ fun SettingsScreen( navigationMode, scrollTo, mainNavController, - settingsViewModel + settingsViewModel, + profileSettingsViewModel ) } @@ -181,16 +179,14 @@ fun SettingsScreenWithScaffold( value = it } } - - Scaffold( - topBar = { - NavigationTopAppBar( - NavigationBarMode.Close, - stringResource(R.string.settings_headline) - ) { mainNavController.popBackStack() } - } + val listState = rememberLazyListState() + AnimatedElevationScaffold( + modifier = Modifier.testTag(TestTag.Settings.SettingsScreen), + navigationMode = NavigationBarMode.Close, + topBarTitle = stringResource(R.string.settings_headline), + onBack = { mainNavController.popBackStack() }, + listState = listState ) { - val listState = rememberLazyListState() var showAllowScreenShotsAlert by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -200,18 +196,15 @@ fun SettingsScreenWithScaffold( SettingsScrollTo.None -> { /* noop */ } - SettingsScrollTo.Authentication -> listState.animateScrollToItem(5) - SettingsScrollTo.DemoMode -> listState.animateScrollToItem(3) - SettingsScrollTo.Profiles -> listState.animateScrollToItem(2) + SettingsScrollTo.Authentication -> listState.animateScrollToItem(4) + SettingsScrollTo.Profiles -> listState.animateScrollToItem(1) + else -> {} } } LazyColumn( modifier = Modifier.testTag("settings_screen"), - contentPadding = rememberInsetsPaddingValues( - insets = LocalWindowInsets.current.navigationBars, - applyBottom = true - ), + contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues(), state = listState ) { item { @@ -220,30 +213,19 @@ fun SettingsScreenWithScaffold( SettingsDivider() } } - item { - OrderHealthCardHint( - modifier = Modifier.fillMaxWidth(), - onClick = { - navController.navigate(SettingsNavigationScreens.OrderHealthCard.path()) - } - ) - SettingsDivider() - } item { ProfileSection(state, settingsViewModel, navController) SettingsDivider() } item { - DemoSection( - state.demoModeActive, - highlighted = scrollTo == SettingsScrollTo.DemoMode, - ) { - if (it) { - settingsViewModel.onActivateDemoMode() - } else { - settingsViewModel.onDeactivateDemoMode() + HealthCardSection( + onClickUnlockEgk = { changeSecret -> + navController.navigate(SettingsNavigationScreens.UnlockEgk.path(changeSecret = changeSecret)) + }, + onClickOrderHealthCard = { + navController.navigate(SettingsNavigationScreens.OrderHealthCard.path()) } - } + ) SettingsDivider() } item { @@ -259,7 +241,7 @@ fun SettingsScreenWithScaffold( val coroutineScope = rememberCoroutineScope() AuthenticationSection(state.authenticationMode) { when (it) { - SettingsScreen.AuthenticationMode.Password -> navController.navigate("Password") + is SettingsData.AuthenticationMode.Password -> navController.navigate("Password") else -> coroutineScope.launch { settingsViewModel.onSelectDeviceSecurityAuthenticationMode() } } } @@ -283,7 +265,7 @@ fun SettingsScreenWithScaffold( item { AllowScreenShotsSection( - state.screenShotsAllowed + state.screenshotsAllowed ) { settingsViewModel.onSwitchAllowScreenshots(it) showAllowScreenShotsAlert = true @@ -292,7 +274,7 @@ fun SettingsScreenWithScaffold( } item { - ContactSection(onClickFeedback = { navController.navigate(SettingsNavigationScreens.FeedbackForm.path()) }) + ContactSection() SettingsDivider() } item { @@ -321,21 +303,16 @@ private fun SettingsDivider() = private fun ProfileSection( state: SettingsScreen.State, viewModel: SettingsViewModel, - navController: NavController, + navController: NavController ) { - val profiles = state.uiProfiles + val profiles = state.profiles var showAddProfileDialog by remember { mutableStateOf(false) } - val allowAddProfiles by produceState(initialValue = false) { - viewModel.allowAddProfiles().collect { - value = it - } - } Column { Text( text = stringResource(R.string.settings_profiles_headline), - style = MaterialTheme.typography.h6, + style = AppTheme.typography.h6, modifier = Modifier .padding( start = PaddingDefaults.Medium, @@ -348,7 +325,6 @@ 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)) } @@ -364,38 +340,23 @@ private fun ProfileSection( ) } - 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() + AddProfile(onClick = { showAddProfileDialog = true }) + 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 + val profileSsoToken = profile.ssoTokenScope?.token Row( modifier = Modifier .fillMaxWidth() .clickable { - if (!demoModeActive) { - onSwitchProfile() - } + onSwitchProfile() }, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically @@ -405,13 +366,14 @@ private fun ProfileCard( .weight(1f) .padding(PaddingDefaults.Medium) ) { - Avatar(Modifier.size(36.dp), profile.name, colors, null, active = profile.active) + Avatar(Modifier.size(36.dp), profile, null, active = profile.active) SpacerSmall() Column { Text( - profile.name, style = MaterialTheme.typography.body1, + profile.name, + style = AppTheme.typography.body1 ) val lastAuthenticatedDateText = @@ -420,24 +382,14 @@ private fun ProfileCard( val connectedColor = connectionTextColor(profileSsoToken) Text( - connectedText, style = AppTheme.typography.captionl, - color = connectedColor, + connectedText, + style = AppTheme.typography.caption1l, + 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) - } - } - ) { + IconButton(onClick = onClickEdit) { Icon(Icons.Outlined.Edit, null, tint = AppTheme.colors.neutral400) } @@ -450,14 +402,14 @@ 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) - ) -} + Icon(Icons.Rounded.Add, null) + SpacerSmall() + Text( + stringResource(R.string.settings_add_profile), + style = AppTheme.typography.body1, + modifier = Modifier.weight(1.0f) + ) + } } @OptIn(ExperimentalComposeUiApi::class) @@ -487,7 +439,7 @@ fun AddProfileDialog( title = { Text( title, - style = MaterialTheme.typography.subtitle1, + style = AppTheme.typography.subtitle1 ) }, properties = DialogProperties(dismissOnClickOutside = false), @@ -496,7 +448,7 @@ fun AddProfileDialog( Column { Text( infoText, - style = MaterialTheme.typography.body2 + style = AppTheme.typography.body2 ) Box(modifier = Modifier.padding(top = 12.dp)) { OutlinedTextField( @@ -504,7 +456,7 @@ fun AddProfileDialog( singleLine = true, onValueChange = { textValue = it.trimStart() - duplicated = state.containsProfileWithName(textValue) + duplicated = state.containsProfileWithName(textValue) && !wantRemoveLastProfile }, keyboardOptions = KeyboardOptions( autoCorrect = true, @@ -512,7 +464,7 @@ fun AddProfileDialog( imeAction = ImeAction.Done ), keyboardActions = KeyboardActions { - if (textValue.isNotEmpty()) { + if (!duplicated && textValue.isNotEmpty()) { onEdit(textValue) } }, @@ -524,7 +476,7 @@ 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) ) } @@ -534,11 +486,12 @@ fun AddProfileDialog( TextButton(onClick = { onDismissRequest() }) { Text(stringResource(R.string.cancel).uppercase(Locale.getDefault())) } - TextButton(onClick = { - if (!duplicated && textValue.isNotEmpty()) { + TextButton( + enabled = !duplicated && textValue.isNotEmpty(), + onClick = { onEdit(textValue) } - }) { + ) { Text(stringResource(R.string.ok).uppercase(Locale.getDefault())) } } @@ -553,58 +506,38 @@ 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 +fun HealthCardSection(onClickUnlockEgk: (changeSecret: Boolean) -> 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 ) - }, - label = "DemoSectionColorAnimation" - ) { - if (it) AppTheme.colors.yellow300 - else MaterialTheme.colors.background - } + ) - LaunchedEffect(highlighted) { - toggle = highlighted - } + LabelButton( + Icons.Outlined.LockOpen, + stringResource(R.string.health_card_section_unlock_card_no_reset) + ) { + onClickUnlockEgk(false) + } - Column(modifier = modifier.background(color)) { - Column( - modifier = Modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(8.dp), + LabelButton( + painterResource(R.drawable.ic_reset_pin), + stringResource(R.string.health_card_section_unlock_card_reset_pin) ) { - Text( - text = stringResource(R.string.settings_demo_headline), - style = MaterialTheme.typography.h6, - modifier = Modifier.testTag("stg_txt_header_demo_mode") - ) - Text( - text = stringResource(R.string.settings_demo_info), - style = AppTheme.typography.body2l - ) + onClickUnlockEgk(true) } - LabeledSwitch( - checked = demoChecked, - onCheckedChange = onDemoChange, - modifier = Modifier.testTag("stg_btn_demo_mode") + + LabelButton( + painterResource(R.drawable.ic_order_egk), + stringResource(R.string.health_card_section_order_card) ) { - Text( - modifier = Modifier.weight(1.0f), - text = stringResource(R.string.settings_demo_toggle), - style = MaterialTheme.typography.body1 - ) + onClickOrderHealthCard() } } } @@ -613,16 +546,16 @@ private fun DemoSection( private fun AccessibilitySection( modifier: Modifier = Modifier, zoomChecked: Boolean, - onZoomChange: (Boolean) -> Unit, + onZoomChange: (Boolean) -> Unit ) { Column(modifier = modifier) { Column( modifier = Modifier.padding(PaddingDefaults.Medium), - verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small), + verticalArrangement = Arrangement.spacedBy(PaddingDefaults.Small) ) { Text( text = stringResource(R.string.settings_accessibility_headline), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) } LabeledSwitch( @@ -635,31 +568,22 @@ private fun AccessibilitySection( } } -@Preview -@Composable -private fun DemoSectionPreview() { - AppTheme { - DemoSection(true, Modifier, false) {} - } -} - @Composable private fun AuthenticationSection( - authenticationMode: SettingsScreen.AuthenticationMode, + authenticationMode: SettingsData.AuthenticationMode, modifier: Modifier = Modifier, - onClickProtectionMode: (SettingsScreen.AuthenticationMode) -> Unit + onClickProtectionMode: (SettingsData.AuthenticationMode) -> Unit ) { - var showBiometricPrompt by rememberSaveable { mutableStateOf(false) } if (showBiometricPrompt) { BiometricPrompt( - authenticationMethod = SettingsAuthenticationMethod.DeviceSecurity, + authenticationMethod = SettingsData.AuthenticationMode.DeviceSecurity, title = stringResource(R.string.auth_prompt_headline), description = "", negativeButton = stringResource(R.string.auth_prompt_cancel), onAuthenticated = { - onClickProtectionMode(SettingsScreen.AuthenticationMode.DeviceSecurity) + onClickProtectionMode(SettingsData.AuthenticationMode.DeviceSecurity) showBiometricPrompt = false }, onCancel = { @@ -680,31 +604,32 @@ private fun AuthenticationSection( ) { Text( text = stringResource(R.string.settings_appprotection_headline), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) Text( text = stringResource(R.string.settings_appprotection_info), - style = MaterialTheme.typography.body2 + style = AppTheme.typography.body2 ) } AuthenticationModeCard( Icons.Outlined.Fingerprint, - checked = authenticationMode == SettingsScreen.AuthenticationMode.DeviceSecurity, + 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, + deviceSecurity = true ) { showBiometricPrompt = true } AuthenticationModeCard( Icons.Outlined.Security, - checked = authenticationMode == SettingsScreen.AuthenticationMode.Password, + 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(SettingsScreen.AuthenticationMode.Password) + // TODO; use enum + onClickProtectionMode(SettingsData.AuthenticationMode.Password("")) } } } @@ -720,7 +645,6 @@ private fun AuthenticationModeCard( enabled: Boolean = true, onClick: () -> Unit ) { - var showAllowDeviceSecurity by remember { mutableStateOf(false) } if (deviceSecurity && showAllowDeviceSecurity && !checked) { @@ -766,7 +690,7 @@ private fun AuthenticationModeCard( Column(modifier = Modifier.weight(1.0f)) { Text( text = headline, - style = MaterialTheme.typography.body1, + style = AppTheme.typography.body1 ) Text( text = info, @@ -776,11 +700,13 @@ private fun AuthenticationModeCard( Box(modifier = Modifier.align(Alignment.CenterVertically)) { Icon( - Icons.Rounded.RadioButtonUnchecked, null, + Icons.Rounded.RadioButtonUnchecked, + null, tint = AppTheme.colors.neutral400 ) Icon( - Icons.Rounded.CheckCircle, null, + Icons.Rounded.CheckCircle, + null, tint = AppTheme.colors.primary600, modifier = Modifier.alpha(alpha.value) ) @@ -801,7 +727,7 @@ private fun DebugMenuSection(navController: NavController) { bottom = PaddingDefaults.Medium / 2, top = PaddingDefaults.Medium ) - .testTag("stg_btn_debug_menu") + .testTag(TestTag.Settings.DebugMenuButton) ) } @@ -811,7 +737,6 @@ private fun AnalyticsSection( modifier: Modifier = Modifier, onCheckedChange: (Boolean) -> Unit ) { - Column { Column( modifier = modifier.padding(PaddingDefaults.Medium), @@ -819,11 +744,11 @@ private fun AnalyticsSection( ) { Text( text = stringResource(R.string.settings_tracking_headline), - style = MaterialTheme.typography.h6 + style = AppTheme.typography.h6 ) Text( text = stringResource(R.string.settings_tracking_info), - style = MaterialTheme.typography.body2 + style = AppTheme.typography.body2 ) } LabeledSwitch( @@ -842,7 +767,7 @@ private fun AnalyticsSection( private fun AllowScreenShotsSection( allowScreenshots: Boolean, modifier: Modifier = Modifier, - onAllowScreenshotsChange: (Boolean) -> Unit, + onAllowScreenshotsChange: (Boolean) -> Unit ) { LabeledSwitch( checked = !allowScreenshots, @@ -876,7 +801,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, @@ -938,8 +863,29 @@ 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) + Text(text, style = AppTheme.typography.body1) + } +} + +@Composable +private fun LabelButton( + icon: Painter, + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(PaddingDefaults.Medium) + .semantics(mergeDescendants = true) {} + ) { + Image(painter = icon, contentDescription = null) + Text(text, style = AppTheme.typography.body1) } } @@ -952,7 +898,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) { @@ -971,118 +917,81 @@ 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 surveyAddress = stringResource(R.string.settings_contact_survey_address) + val mailAddress = stringResource(R.string.settings_contact_mail_address) + val subject = stringResource(R.string.settings_feedback_mail_subject) + val body = buildFeedbackBodyWithDeviceInfo() + 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)) } + onClick = { context.handleIntent(provideWebIntent(surveyAddress)) } + ) + LabelButton( + 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( + title: String = stringResource(R.string.settings_feedback_mail_title), + userHint: String = stringResource(R.string.seetings_feedback_form_additional_data_info), + 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} + | +""".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 854865cd..20987e76 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,124 +23,100 @@ 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 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 kotlinx.coroutines.ExperimentalCoroutinesApi +import de.gematik.ti.erp.app.analytics.Analytics +import kotlinx.coroutines.Dispatchers 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)) 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) - suspend fun onSelectDeviceSecurityAuthenticationMode() = - settingsUseCase.saveAuthenticationMethod( - SettingsAuthenticationMethod.DeviceSecurity - ) + fun pairedDevices(profileId: ProfileIdentifier) = + profilesWithPairedDevicesUseCase.pairedDevices(profileId) - suspend fun onSelectPasswordAsAuthenticationMode(password: String) = - settingsUseCase.savePasswordAsAuthenticationMethod(password) + // 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.saveAuthenticationMode( + SettingsData.AuthenticationMode.DeviceSecurity + ) + } + + fun onSelectPasswordAsAuthenticationMode(password: String) = + viewModelScope.launch(Dispatchers.IO) { + settingsUseCase.saveAuthenticationMode(SettingsData.AuthenticationMode.Password(password = password)) + } fun onSwitchAllowScreenshots(allowScreenshots: Boolean) { appPrefs.edit { @@ -161,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) { @@ -189,55 +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 isFeatureBioLoginEnabled() = toggleManager.isFeatureEnabled(Features.BIO_LOGIN.featureName) - - fun isCanAvailable(profile: ProfilesUseCaseData.Profile) = - runBlocking { - profilesUseCase.isCanAvailable(profile).first() - } + fun loadAuditEventsForProfile(profileId: ProfileIdentifier): Flow> = + profilesUseCase.auditEvents(profileId) - fun loadAuditEventsForProfile(profileName: String): Flow> = - profilesUseCase.loadAuditEventsForProfile(profileName) - - 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..821c1071 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,23 @@ 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 } var showDataTermsUpdate: Flow = - settings.map { - it.dataProtectionVersionAccepted < DATA_PROTECTION_LAST_UPDATED - } + settingsRepository.general.map { it.dataProtectionVersionAcceptedOn < DATA_PROTECTION_LAST_UPDATED } - val pharmacySearch = - settings.map { it.pharmacySearch } + override val general: Flow + get() = settingsRepository.general - suspend fun savePharmacySearch( - name: String, - locationEnabled: Boolean, - filterReady: Boolean, - filterDeliveryService: Boolean, - filterOnlineService: Boolean, - filterOpenNow: Boolean + suspend fun onboardingSucceeded( + authenticationMode: SettingsData.AuthenticationMode, + defaultProfileName: String, + now: Instant = Instant.now() ) { - settingsRepository.savePharmacySearch( - name = name, - locationEnabled = locationEnabled, - filterReady = filterReady, - filterDeliveryService = filterDeliveryService, - filterOnlineService = filterOnlineService, - filterOpenNow = filterOpenNow - ) - } - - suspend fun saveAuthenticationMethod(authenticationMethod: SettingsAuthenticationMethod) { - settingsRepository.saveAuthenticationMethod(authenticationMethod) - } - - suspend fun savePasswordAsAuthenticationMethod(password: String) { - settingsRepository.savePasswordAsAuthenticationMethod(password) - } - - 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 + 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 8fd54016..00000000 --- a/android/src/main/java/de/gematik/ti/erp/app/tracking/Tracker.kt +++ /dev/null @@ -1,146 +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 de.gematik.ti.erp.app.demo.usecase.DemoUseCase -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -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 - -private const val trackerName = "Tracker" - -// `gemSpec_eRp_FdV A_20187` -@Singleton -class Tracker @Inject constructor( - @ApplicationContext private val context: Context, - private val demoUseCase: DemoUseCase -) { - 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) { - val p = if (demoUseCase.isDemoModeActive) "demo/$path" else path - TrackHelper.track().screen(p).with(tracker) - } - - fun trackIdentifiedWithIDP() { - TrackHelper.track().event("CardWall", "Identified").with(tracker) - } - - enum class AuthenticationProblem { - CardBlocked, - CardAccessNumberWrong, - CardCommunicationInterrupted, - CardPinWrong, - IDPCommunicationFailed, - IDPCommunicationInvalidCertificate, - IDPCommunicationInvalidOCSPOfCard, - SecureElementCryptographyFailed - } - - fun trackAuthenticationProblem(kind: AuthenticationProblem) { - if (!demoUseCase.isDemoModeActive) { - TrackHelper.track().event("CardWall", "AuthFailed-${kind.name}").with(tracker) - } - } - - fun trackSaveScannedPrescriptions() { - TrackHelper.track().event("Scanner", "ScannedCodesSaved").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..dfc0839b 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,86 @@ 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_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.Job +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.combineTransform import kotlinx.coroutines.flow.distinctUntilChanged -import javax.inject.Inject -import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +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) + +// tag::AuthenticationUseCase[] +class AuthenticationUseCase( + private val settingsUseCase: SettingsUseCase, + private val dispatchers: DispatchProvider +) : LifecycleEventObserver { private enum class Lifecycle { Started, Running, Stopped } + private val scope = CoroutineScope(dispatchers.Default) + private val authRequired = MutableStateFlow(false) private val lifecycle = MutableStateFlow(Lifecycle.Started) + private val timerResetChannel = Channel(Channel.CONFLATED) - val authenticationModeAndMethod = - combineTransform(lifecycle, authRequired, settingsUseCase.settings) { lifecycle, authRequired, settings -> + private fun authenticationFlow() = + combineTransform( + lifecycle, + authRequired, + settingsUseCase.authenticationMode, + settingsUseCase.general + ) { lifecycle, authRequired, authenticationMode, settings -> @Suppress("deprecation") when (lifecycle) { Lifecycle.Started -> { - this@AuthenticationUseCase.authRequired.value = when (settings.authenticationMethod) { - SettingsAuthenticationMethod.None, - SettingsAuthenticationMethod.Unspecified -> false + this@AuthenticationUseCase.authRequired.value = when (authenticationMode) { + SettingsData.AuthenticationMode.None, + SettingsData.AuthenticationMode.Unspecified -> false else -> true } 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 ) ) @@ -79,30 +110,66 @@ class AuthenticationUseCase @Inject constructor( } }.distinctUntilChanged() + val authenticationModeAndMethod: Flow = + channelFlow { + val timer = suspend { + Napier.d { "Restarted inactivity timer for $InactivityTimeout" } + delay(InactivityTimeout.toMillis()) + requireAuthentication() + } + + launch { + var timerJob: Job? = null + for (ignored in timerResetChannel) { + timerJob?.cancel() + timerJob = launch { timer() } + } + } + + Napier.d { "Started authentication flow" } + + authenticationFlow() + .collect { + if (it == AuthenticationModeAndMethod.Authenticated) { + timerResetChannel.send(Unit) + } + + 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() { + timerResetChannel.trySendBlocking(Unit) } fun authenticated() { authRequired.value = false } + private fun requireAuthentication() { + authRequired.value = true + } + 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) { + when (event) { + ON_START -> lifecycle.value = Lifecycle.Started + ON_STOP -> lifecycle.value = Lifecycle.Stopped + 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 c71ec697..ae56fef6 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,10 +34,9 @@ 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.ExperimentalMaterialApi 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 @@ -51,28 +53,23 @@ 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.cardwall.ui.PrimaryButton +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 @@ -89,22 +86,16 @@ 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") +@OptIn(ExperimentalMaterialApi::class) @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 { @@ -115,156 +106,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 { 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 ) } @@ -273,7 +175,7 @@ fun UserAuthenticationScreen(userAuthViewModel: UserAuthenticationViewModel = hi if (showAuthPrompt) { when (state.authenticationMethod) { - SettingsAuthenticationMethod.Password -> + is SettingsData.AuthenticationMode.Password -> PasswordPrompt( userAuthViewModel, onAuthenticated = { @@ -315,6 +217,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, @@ -346,8 +405,7 @@ private fun PasswordPrompt( onAuthenticationError() } } - }, - modifier = Modifier.testId("auth/forward") + } ) { Text(stringResource(R.string.auth_prompt_check_password).uppercase(Locale.getDefault())) } @@ -356,7 +414,6 @@ private fun PasswordPrompt( text = { PasswordTextField( modifier = Modifier - .testId("auth/passwordInput") .fillMaxWidth() .heightIn(min = 56.dp), value = password, @@ -373,28 +430,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..a5df149e 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 @@ -27,6 +27,7 @@ import java.time.format.FormatStyle val dateTimeShortFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) fun dateTimeShortText(instant: Instant): String = LocalDateTime.ofEpochSecond( - instant.epochSecond, 0, + instant.epochSecond, + 0, ZoneOffset.UTC ).atOffset(ZoneOffset.UTC).format(dateTimeShortFormatter) 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..be96a79d 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 @@ -81,3 +81,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 3f994e0d..b1f8df25 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,13 +19,16 @@ 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.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 @@ -44,6 +47,7 @@ fun AnimatedElevationScaffold( onBack: () -> Unit, content: @Composable (PaddingValues) -> Unit ) { + val elevated by derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } Scaffold( modifier = modifier, scaffoldState = scaffoldState, @@ -52,7 +56,7 @@ fun AnimatedElevationScaffold( navigationMode = navigationMode, backgroundColor = topBarColor, title = topBarTitle, - elevation = if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + elevation = if (elevated) { AppBarDefaults.TopAppBarElevation } else { 0.dp @@ -69,11 +73,46 @@ fun AnimatedElevationScaffold( fun AnimatedElevationScaffold( modifier: Modifier = Modifier, topBarColor: Color = MaterialTheme.colors.surface, - navigationMode: NavigationBarMode = NavigationBarMode.Close, + navigationMode: NavigationBarMode? = NavigationBarMode.Close, + bottomBar: @Composable () -> Unit = {}, + topBarTitle: String, + listState: LazyListState, + onBack: () -> Unit, + actions: @Composable RowScope.() -> Unit, + content: @Composable (PaddingValues) -> Unit +) { + val elevated by derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } + Scaffold( + modifier = modifier, + topBar = { + NavigationTopAppBar( + navigationMode = navigationMode, + backgroundColor = topBarColor, + title = topBarTitle, + elevation = if (elevated) { + AppBarDefaults.TopAppBarElevation + } else { + 0.dp + }, + onBack = onBack, + actions = actions + ) + }, + 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 } @@ -86,10 +125,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/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 60fb82d0..79bb29c2 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 @@ -31,7 +31,6 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -56,7 +55,6 @@ 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 @@ -91,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 @@ -116,16 +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 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,11 @@ fun NavigationClose(modifier: Modifier = Modifier, onClick: () -> Unit) { IconButton( onClick = onClick, modifier = modifier - .testId("nav_btn_back") - .semantics { contentDescription = acc } + .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 +274,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 +328,11 @@ 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,10 +346,11 @@ 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 = { @@ -349,12 +361,13 @@ fun NavigationTopAppBar( 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, @@ -371,56 +384,9 @@ 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 - - Row( - modifier = Modifier.weight(1.0f) - ) { - Icon(icon, null, tint = iconColorTint) - Column( - modifier = Modifier - .weight(1.0f) - .padding(horizontal = PaddingDefaults.Small) - ) { - Text( - text = header, - style = MaterialTheme.typography.body1, - color = textColor - ) - if (description != null) Text( - text = description, - style = AppTheme.typography.body2l, - color = textColor - ) - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun LabeledSwitchWithLink( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - icon: ImageVector, - header: String, - description: String, - link: String, - onClickLink: () -> (Unit), -) { - LabeledSwitch( - checked = checked, - onCheckedChange = onCheckedChange, - 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) @@ -433,26 +399,21 @@ fun LabeledSwitchWithLink( ) { Text( text = header, - style = MaterialTheme.typography.body1, + style = AppTheme.typography.body1, color = textColor ) - Text( - text = description, - style = AppTheme.typography.body2l, - color = textColor - ) - Text( - text = link, - style = AppTheme.typography.body2l, - color = AppTheme.colors.primary600, - modifier = Modifier.clickable { onClickLink() } - ) + if (description != null) { + Text( + text = description, + style = AppTheme.typography.body2l, + color = descriptionColor + ) + } } } } } -@OptIn(ExperimentalMaterialApi::class) @Composable fun LabeledSwitch( checked: Boolean, @@ -477,7 +438,6 @@ fun LabeledSwitch( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - label() // for better visibility in dark mode @@ -499,22 +459,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 @@ -537,23 +482,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) = @@ -565,48 +518,72 @@ 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( title = header?.let { { Text(header) } }, onDismissRequest = onCancel, text = { Text(info) }, + icon = icon, buttons = { - TextButton(onClick = onCancel) { - Text(cancelText.uppercase(Locale.getDefault())) + TextButton(onClick = onCancel, enabled = enabled) { + Text(cancelText) } - TextButton(onClick = onClickAction) { - Text(actionText.uppercase(Locale.getDefault())) + TextButton(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 @@ -614,7 +591,7 @@ fun AcceptDialog( header: String, info: String, acceptText: String, - onClickAccept: () -> Unit, + onClickAccept: () -> Unit ) = AlertDialog( title = { Text(header) }, @@ -622,7 +599,7 @@ fun AcceptDialog( text = { Text(info) }, buttons = { TextButton(onClick = onClickAccept) { - Text(acceptText.uppercase(Locale.getDefault())) + Text(acceptText) } }, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) @@ -659,7 +636,7 @@ fun Context.handleIntent( try { startActivity(intent) } catch (e: ActivityNotFoundException) { - Timber.e(e) + Napier.e("Couldn't start intent", e) onCouldNotHandleIntent?.let { it() } } } @@ -687,7 +664,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 { @@ -745,10 +722,13 @@ 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)) } } @@ -766,7 +746,7 @@ fun InputField( keyBoardType: KeyboardType? = null ) { val initialValue = rememberSaveable { value } - + val undoDescription = stringResource(R.string.onb_undo_description) Column { OutlinedTextField( value = value, @@ -792,7 +772,11 @@ fun InputField( isError = isError, trailingIcon = if (initialValue != value) { { - IconButton(onClick = { onValueChange(initialValue) }) { + IconButton( + modifier = Modifier + .semantics { contentDescription = undoDescription }, + onClick = { onValueChange(initialValue) } + ) { Icon(Icons.Rounded.Undo, null) } } @@ -803,7 +787,7 @@ fun InputField( if (isError) { errorText?.let { CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.caption, + LocalTextStyle provides AppTheme.typography.caption1, LocalContentColor provides AppTheme.colors.red600 ) { Box(Modifier.padding(start = PaddingDefaults.Medium, top = PaddingDefaults.Small)) { @@ -831,3 +815,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(stringId: Int, instant: Instant): String { + val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } + val date = remember { + instant.atZone(ZoneId.systemDefault()) + .toLocalDate().format(dateFormatter) + } + val combinedString = annotatedStringResource(stringId, 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 a437ed35..2f35aca8 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 @@ -35,6 +35,7 @@ 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 +45,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.imePadding +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), @@ -113,8 +116,12 @@ fun AlertDialog( 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 +129,7 @@ fun AlertDialog( } } CompositionLocalProvider( - LocalTextStyle provides MaterialTheme.typography.body2 + LocalTextStyle provides AppTheme.typography.body2 ) { text?.let { text() 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..1d7eadb0 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) } } @@ -390,6 +389,7 @@ fun HintTextLearnMoreButton( text = stringResource(R.string.learn_more_btn) ) } + @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 8c3bb156..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 @@ -35,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( @@ -49,7 +52,7 @@ fun TopAppBar( actions: @Composable RowScope.() -> Unit = {}, backgroundColor: Color = MaterialTheme.colors.primarySurface, contentColor: Color = contentColorFor(backgroundColor), - elevation: Dp = AppBarDefaults.TopAppBarElevation, + elevation: Dp = AppBarDefaults.TopAppBarElevation ) { Surface( modifier = modifier, @@ -94,7 +97,7 @@ fun TopAppBarWithContent( actions, backgroundColor, contentColor, - elevation = 0.dp, + elevation = 0.dp ) content() } @@ -135,7 +138,7 @@ fun BottomNavigation( contentColor: Color = contentColorFor(backgroundColor), elevation: Dp = BottomNavigationDefaults.Elevation, extraContent: @Composable () -> Unit, - content: @Composable RowScope.() -> Unit, + content: @Composable RowScope.() -> Unit ) { Surface( modifier = modifier, @@ -156,11 +159,8 @@ fun BottomNavigation( } } -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/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/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/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/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/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_blue_circle.webp b/android/src/main/res/drawable-xhdpi/doctor_blue_circle.webp new file mode 100644 index 00000000..2a2c0d54 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/doctor_blue_circle.webp 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/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_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_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/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_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/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/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/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/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/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_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/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/woman_red_shirt_overlapping.webp b/android/src/main/res/drawable-xhdpi/woman_red_shirt_overlapping.webp new file mode 100644 index 00000000..83751871 Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/woman_red_shirt_overlapping.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_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-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/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/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/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/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_blue_circle.webp b/android/src/main/res/drawable-xxhdpi/doctor_blue_circle.webp new file mode 100644 index 00000000..a0034f71 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/doctor_blue_circle.webp 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/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_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_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/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_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/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/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/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/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/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_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/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/woman_red_shirt_overlapping.webp b/android/src/main/res/drawable-xxhdpi/woman_red_shirt_overlapping.webp new file mode 100644 index 00000000..9d3f468f Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/woman_red_shirt_overlapping.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_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-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/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/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/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/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_blue_circle.webp b/android/src/main/res/drawable-xxxhdpi/doctor_blue_circle.webp new file mode 100644 index 00000000..1b240751 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/doctor_blue_circle.webp 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/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_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_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/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_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/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/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/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/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/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_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/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/woman_red_shirt_overlapping.webp b/android/src/main/res/drawable-xxxhdpi/woman_red_shirt_overlapping.webp new file mode 100644 index 00000000..1e424257 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/woman_red_shirt_overlapping.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_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/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_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.json b/android/src/main/res/raw/health_insurance_contacts.json index 28ff70b9..c01059ed 100644 --- a/android/src/main/res/raw/health_insurance_contacts.json +++ b/android/src/main/res/raw/health_insurance_contacts.json @@ -1,10 +1,10 @@ [ { "name": "AOK Baden-Württemberg", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "a99_egk@bw.aok.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -13,20 +13,20 @@ { "name": "AOK Bayern", "healthCardAndPinPhone": "+498922844050", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", + "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", @@ -37,7 +37,7 @@ "healthCardAndPinPhone": "+49471160", "healthCardAndPinMail": "info@hb.aok.de", "healthCardAndPinUrl": "https://www.aok.de/pk/bremen/inhalt/elektronische-gesundheitskarte-3/", - "pinUrl": "", + "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", @@ -45,197 +45,197 @@ }, { "name": "AOK - Die Gesundheitskasse Hessen", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "service@he.aok.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK - Die Gesundheitskasse Niedersachsen", "healthCardAndPinPhone": "+498000265637", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK Nordost", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "eGK_online@nordost.aok.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK Nordwest - Die Gesundheitskasse", "healthCardAndPinPhone": "+498002655060", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", - "healthCardAndPinMail": "", + "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK Rheinland/Hamburg - Die Gesundheitskasse", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "aok@rh.aok.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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": "#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" + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK Rheinland-Pfalz/Saarland - Die Gesundheitskasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.aok.de/pk/rps/inhalt/die-haeufigsten-fragen-und-antworten-zum-e-rezept-4/", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "AOK Sachsen-Anhalt - Die Gesundheitskasse", "healthCardAndPinPhone": "+4908002265725", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Audi BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.audibkk.de/e-rezept-gematik-egkpin/", "pinUrl": "https://www.audibkk.de/e-rezept-gematik-pin/", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Deutsche Bahn AG", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bahn-bkk.de/egk-erezept", "pinUrl": "https://www.bahn-bkk.de/egk-erezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Deutsche Bank AG", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkkdb.de/leistungen-beratung/alle-leistungen/alle-leistungen-von-a-z/versichertenkarte", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BARMER", "healthCardAndPinPhone": "+498003331010", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.barmer.de/gematik-eRezept", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "DIE BERGISCHE KRANKENKASSE", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bergische-krankenkasse.de/digital/e-rezept", "pinUrl": "https://www.bergische-krankenkasse.de/digital/e-rezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Bertelsmann BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bertelsmann-bkk.de/erezept-egk-pin", "pinUrl": "https://www.bertelsmann-bkk.de/erezept-egk-pin", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BIG direkt gesund", "healthCardAndPinPhone": "+4980054565456", - "healthCardAndPinMail": "", + "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Akzo Nobel Bayern", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-akzo.de/service/elektronische-gesundheitskarte-egk", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK B. Braun Aesculap", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-bba.de/egk-pin-bestellen", "pinUrl": "https://www.bkk-bba.de/pin-bestellen", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK BPW Bergische Achsen KG", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -243,65 +243,65 @@ }, { "name": "BKK EUREGIO", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-euregio.de/elektronische-gesundheitskarte", "pinUrl": "https://www.bkk-euregio.de/elektronische-gesundheitskarte", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK EVM", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Diakonie", "healthCardAndPinPhone": "+49521329876120", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://bkkexklusiv.de/gesundheitskarte", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Faber-Castell & Partner", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "erezept@bkk-faber-castell.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -310,101 +310,101 @@ { "name": "BKK firmus", "healthCardAndPinPhone": "+4942164343", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-firmus.de/beratung-und-service/online-tools/e-rezept.html", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Freudenberg", "healthCardAndPinPhone": "+4962016905001", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK GILDEMEISTER SEIDENSTICKER", "healthCardAndPinPhone": "+498000255255", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkkgs.de/e-rezept", "pinUrl": "https://www.bkkgs.de/e-rezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK GRILLO-WERKE AG", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Groz-Beckert", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "info@bkk-gb.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Herford Minden Ravensberg", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK HMR", "healthCardAndPinPhone": "+4952211026210", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK KARL MAYER", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Linde", @@ -412,17 +412,17 @@ "healthCardAndPinMail": "egk@bkk-linde.de", "healthCardAndPinUrl": "https://bkkln.de/epa-egkpin", "pinUrl": "https://bkkln.de/epa-egkpin", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", + "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", @@ -430,32 +430,32 @@ }, { "name": "BKK MAHLE", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-mahle.de/service/elektronische-gesundheitskarte-egk", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Miele", "healthCardAndPinPhone": "+498008002189", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.miele-bkk.de/service/elektronische-gesundheitskarte", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", + "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", @@ -465,52 +465,52 @@ "name": "BKK PFAFF", "healthCardAndPinPhone": "+49631318760", "healthCardAndPinMail": "info@bkk-pfaff.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Pfalz", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkkpfalz.de/service-informationen/elektronische-gesundheitskarte", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK ProVita", "healthCardAndPinPhone": "+498006648808", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://bkk-provita.de/service-info/e-rezept/", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Public", "healthCardAndPinPhone": "+495341405600", "healthCardAndPinMail": "service@bkk-public.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK PricewaterhouseCoopers", "healthCardAndPinPhone": "+498002557920", "healthCardAndPinMail": "erezept@bkk-pwc.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -519,31 +519,31 @@ { "name": "BKK Rieker RICOSTA Weisser", "healthCardAndPinPhone": "+4974625793030", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK RWE", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkkrwe.de/e-rezept", "pinUrl": "https://www.bkkrwe.de/e-rezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Salzgitter", "healthCardAndPinPhone": "+495341405700", "healthCardAndPinMail": "service@bkk-salzgitter.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -551,76 +551,76 @@ }, { "name": "BKK SBH", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-sbh.de/e-rezept/", "pinUrl": "https://bkk-sbh.de/e-rezept/", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Scheufelen", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-scheufelen.de/e-rezept", "pinUrl": "https://www.bkk-scheufelen.de/e-rezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Schwarzwald-BaarHeuberg", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK STADT AUGSBURG", "healthCardAndPinPhone": "+498213243231", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Technoform", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-technoform.de/index.php?p=page&ID=11", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Textilgruppe Hof", "healthCardAndPinPhone": "+498002558440", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Verkehrsbau Union (VBU)", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "info@bkk-vbu.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -629,31 +629,31 @@ { "name": "BKK VDN", "healthCardAndPinPhone": "+49230498260", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK VerbundPlus", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-verbundplus.de/ihre-mitgliedschaft/elektronische-gesundheitskarte/", "pinUrl": "https://www.bkk-verbundplus.de/nfc-karte-pin", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Voralb", "healthCardAndPinPhone": "+4970229324639", "healthCardAndPinMail": "beitraege@bkk-voralb.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -663,8 +663,8 @@ "name": "BKK Werra-Meissner", "healthCardAndPinPhone": "+490565174510", "healthCardAndPinMail": "info@bkk-wm.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -672,21 +672,21 @@ }, { "name": "BKK Wirtschaft & Finanzen", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk-wf.de/e-rezept/", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK Würth", "healthCardAndPinPhone": "+49794091900", "healthCardAndPinMail": "info@bkk-wuerth.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -695,57 +695,57 @@ { "name": "BKK ZF & Partner", "healthCardAndPinPhone": "+493381306652512", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BKK24", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bkk24.de/e-rezept", "pinUrl": "https://bkk24.de/e-rezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "BMW BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.bmwbkk.de/egk", "pinUrl": "https://www.bmwbkk.de/egk-pin-puk", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Bosch BKK", - "healthCardAndPinPhone": "", + "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Continentale BKK", @@ -753,61 +753,61 @@ "healthCardAndPinMail": "kundenservice@continentale-bkk.de", "healthCardAndPinUrl": "https://www.continentale-bkk.de/kontakt/kontaktformular/", "pinUrl": "https://www.continentale-bkk.de/kontakt/kontaktformular/", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Daimler BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.daimler-bkk.com/service/erezept", "pinUrl": "https://www.daimler-bkk.com/service/erezept", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "DAK-Gesundheit", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Debeka BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, "pinUrl": "https://www.debeka-bkk.de/erezept/", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "energie - Betriebskrankenkasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", - "pinUrl": "", + "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", @@ -815,120 +815,120 @@ }, { "name": "Heimat Krankenkasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.heimat-krankenkasse.de/egk-anfordern", "pinUrl": "https://www.heimat-krankenkasse.de/egk-pin-anfordern", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "HEK - Hanseatische Krankenkasse", "healthCardAndPinPhone": "+498000213213", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, "pinUrl": "https://www.hek.de/egk", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Handelskrankenkasse (hkk)", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "IKK - Die Innovationskasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.die-ik.de/e-rezept", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "IKK Brandenburg und Berlin", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.ikkbb.de/erezept/auth-egk", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "IKK classic", "healthCardAndPinPhone": "+498004551111", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "IKK gesund plus", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "IKK Südwest", "healthCardAndPinPhone": "+498000119119", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.ikk-suedwest.de/service/persoenlicher-kundenberater/", "pinUrl": "https://www.ikk-suedwest.de/service/persoenlicher-kundenberater/", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Kaufmännische Krankenkasse - KKH", "healthCardAndPinPhone": "+498005548640554", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "KNAPPSCHAFT", "healthCardAndPinPhone": "+498000200501", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Koenig & Bauer BKK", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "erezept@koenig-bauer-bkk.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -936,10 +936,10 @@ }, { "name": "Krones BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -948,35 +948,35 @@ { "name": "Merck BKK", "healthCardAndPinPhone": "+496151722256", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinMail": null, + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "mhplus Betriebskrankenkasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.mhplus-krankenkasse.de/privatkunden/unser-service/services-fuer-mitglieder/mhplus-gesundheitskarte", "pinUrl": "https://iam.mhplusdirekt.de/pinaas/pin-request-with-egk", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Mobil Krankenkasse", "healthCardAndPinPhone": "+498002550800", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://mobil-krankenkasse.de/unser-service/e-rezept.html", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Novitas BKK", @@ -992,24 +992,24 @@ { "name": "pronova BKK", "healthCardAndPinPhone": "+49621533911000", - "healthCardAndPinMail": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.pronovabkk.de/leistungen/elektronische-gesundheitskarte", "pinUrl": "https://meine.pronovabkk.de/", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "R+V Betriebskrankenkasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Salus BKK", @@ -1017,39 +1017,39 @@ "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "SIEMAG BKK", "healthCardAndPinPhone": "+492733292929", "healthCardAndPinMail": "info@siemagbkk.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Siemens-Betriebskrankenkasse (SBK)", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://meine.sbk.org/pin_gesundheitskarte", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "SECURVITA BKK", "healthCardAndPinPhone": "+494033477", "healthCardAndPinMail": "egk@securvita-bkk.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", @@ -1057,69 +1057,69 @@ }, { "name": "SKD BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.skd-bkk.de/leistungen/26-elektronische-gesundheitskarte-egk/", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Südzucker BKK", "healthCardAndPinPhone": "+496213285845", - "healthCardAndPinMail": "", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "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": "", + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://portal.svlfg.de/svlfg-apps/gesundheitskarte", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "Techniker Krankenkasse", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "healthCardAndPinPhone": null, + "healthCardAndPinMail": null, "healthCardAndPinUrl": "https://www.tk.de/techniker/2113848", "pinUrl": "https://www.tk.de/techniker/2113852", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "TUI BKK", "healthCardAndPinPhone": "+495341405800", "healthCardAndPinMail": "service@tui-bkk.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "VIACTIV BKK", "healthCardAndPinPhone": "+498002221211", "healthCardAndPinMail": "service@viactiv.de", - "healthCardAndPinUrl": "", - "pinUrl": "", - "subjectCardAndPinMail": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "healthCardAndPinUrl": null, + "pinUrl": null, + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "vivida bkk", @@ -1134,21 +1134,21 @@ }, { "name": "Wieland BKK", - "healthCardAndPinPhone": "", - "healthCardAndPinMail": "", + "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": "", - "bodyCardAndPinMail": "", - "subjectPinMail": "", - "bodyPinMail": "" + "subjectCardAndPinMail": null, + "bodyCardAndPinMail": null, + "subjectPinMail": null, + "bodyPinMail": null }, { "name": "WMF Betriebskrankenkasse", - "healthCardAndPinPhone": "", + "healthCardAndPinPhone": null, "healthCardAndPinMail": "service@wmf-bkk.de", - "healthCardAndPinUrl": "", - "pinUrl": "", + "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", 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..79c671d2 --- /dev/null +++ b/android/src/main/res/raw/nfc_positions.json @@ -0,0 +1,1228 @@ +[ + { + "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.1970802919708029, + "y0": 0.11612903225806452, + "x1": 0.7299270072992701, + "y1": 0.3709677419354839 + }, + { + "marketingName": "Samsung Galaxy S20+", + "modelNames": [ + "SM-G985F" + ], + "x0": 0.0714285714285714, + "y0": 0.08389261744966443, + "x1": 0.6071428571428572, + "y1": 0.3221476510067114 + }, + { + "marketingName": "Samsung Galaxy S20", + "modelNames": [ + "SM-G980F" + ], + "x0": 0.07092198581560283, + "y0": 0.11935483870967742, + "x1": 0.6312056737588653, + "y1": 0.3387096774193548 + }, + { + "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": [], + "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": "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 + } +] \ 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..4a515278 100644 --- a/android/src/main/res/values-ar/strings.xml +++ b/android/src/main/res/values-ar/strings.xml @@ -1,503 +1,749 @@ - الوصفة الإلكترونية - موافق - إلغاء - تراجع - الساعة - في تمام %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 محاولة أخرى قبل وقف البطاقة. + لديك عدد %s محاولات أخرى قبل وقف البطاقة. + + + . + + + تم إدخال CAN خاطيء + تجد رقم تسجيل الدخول أعلى يمينًا في بطاقتك الصحية. + إلغاء + البحث عن بطاقة... + ضع بطاقتك الصحية على ظهر الجهاز. + لا يزال البحث جاريًا ... + ضع البطاقة ببطء على ظهر الجهاز. + نصيحة + يمكن لأغلفة حماية الجهاز أن تجعل الاتصال عبر NFC أكثر صعوبة. + تم التعرف على البطاقة + حاول عدم تحريك البطاقة الصحية. + تم العثور على البطاقة الصحية. من فضلك لا تحركها. + فُقد الاتصال + ضع بطاقتك الصحية من جديد على ظهر الجهاز + سجلت دخولك بنجاح + ملاحظة: يتم تنزيل الوصفات الطبية في آخر 100 يوم فقط. + النسخة: %s + Build-Hash: %s + قائمة التنقيح + كود الوصفة الطبية + قم بمسح كود الوصفة الطبية في صيدليتك. + يحتوي هذا الكود الجماعي على %s وصفات طبية + الصرف في الصيدلية + أنت توجد في صيدلية وتريد صرف وصفتك الطبية. + اطلب أو احجز + أرسل وصفتك الطبية إلى الصيدلية وقرر الطريقة التي ترغب بها في تلقي الدواء. + اختر الصيدلية + مثل البحث عن الاسم أو العنوان + البحث عن الصيدليات بسهولة + حدد موقعك وابحث عن الصيدليات في منطقتك + إتاحة المقر + مفتوح حتى الساعة %s + مفتوح دائمًا + هيئة التحرير + الناشر + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + المدير التنفيذي: الدكتور طبيب ماركوس ليك ديكن\nالمحكمة المختصة بالتسجيل: المحكمة الابتدائية في شارلوتنبورغ\nرقم السجل التجاري.: HRB 96351\nرقم تعريف ضريبة القيمة المضافة: DE241843684 + المسؤول عن المحتوى + الدكتور طبيب ماركوس ليك ديكن + الاتصال + ملاحظة + نسعى جاهدين من أجل استخدام لغة منصفة بين الجنسين. إذا لاحظت أية أخطاء، فإننا نتطلع إلى إرسال رسالة لنا عبر البريد الإلكتروني. + المنصة الألمانية الحديثة للطب الرقمي + كتابة بريد إلكتروني + فتح الموقع الإلكتروني + أهلًا وسهلًا + ابدأ تسجيل الدخول + الفتح + التسجيل + إلغاء + الإعدادات + اسم غير معروف + البطاقات الصحية + إضافة بطاقة + الأمان + قم بحماية معلوماتك الصحية من الوصول غير المصرح لهم. + تعليمات قانونية + هيئة التحرير + حماية البيانات + شروط الاستخدام + ليس لديك أي وصفات طبية في الوقت الراهن + تأمين بيانات الوصفات + حماية أفضل لبياناتك باستخدام بصمة الإصبع أو الوجه. + التفعيل الآن + التفاصيل + احتفظ بنظرة عامة + تحديد هذه الوصفة باعتبارها تم صرفها بمجرد حصولك على أدويتك. + الدواء %1$d + وضع علامة \"تم الصرف\" + وضع علامة \"لم يتم الصرف\" + الحذف من هذا الجهاز + شكل الجرعة + حجم العبوة + الرقم المركزي الصيدلي (PZN) + تعليمات تناول الدواء + يُرجى الانتباه إلى تعليمات تناول الدواء في خطة علاجك أو تعليمات حجم الجرعة التي حددها لك طبيبك بشكل مكتوب. + الشخص المؤمن عليه + الاسم + العنوان + تاريخ الميلاد + التأمين الصحي / القائم بالدفع + الحالة + الرقم التأميني + الشخص واصف الدواء + الاسم + الإخصائية / الأخصائي + رقم الطبيب (LANR) + المؤسسة + الاسم + العنوان + رقم المُنْشَأَة + رقم الهاتف + البريد الإلكتروني + حادث عمل + يوم الحادث + رقم شركة التأمين على الحوادث أو صاحب العمل + هل ترغب في حذف هذه الوصفة بشكل دائم؟ + حذف + إلغاء + السرعة مطلوبة هنا + يمكن أيضًا صرف هذا الدواء من الصيدلية ليلاً بدون رسوم خدمة الطوارئ. + يمكن تلقي مستحضرًا طبيًا بديلًا + يُسمح بالمستحضرات الطبية البديلة. نظرا للمتطلبات القانونية للتأمين الصحي الخاص بك، يمكن أن تسليمك بديل. + احجز بشكل مُلزم + اطلب خدمة المراسلة + اطلب خدمة التوصيل + يرجى الانتباه إلى أنه قد يتم تطبيق رسوم إضافية مقابل الأدوية الموصوفة أيضًا. + أوقات العمل + الموقع الإلكتروني + هل ترغب في صرف الوصفات الطبية في %s بشكل ملزم؟ + قابلة للصرف اليوم فقط كدافع ذاتي + التسجيل + هاتف ذكي يدعم تقنية NFC ويعمل بنظام Android 7 على الأقل + تفعيل وظيفة NFC + يُرجى تفعيل وظيفة NFC بجهازك لتتمكن من تسجيل الدخول باستخدام بطاقتك الصحية. + تفعيل + كيف أحصل على بطاقة صحية جديدة؟ + هنا تساعدك شركة التأمين الصحي الخاصة بك. + كيف أحصل على رقم التعريف الشخصي؟ + تحصل على رقم التعريف الشخصي لبطاقتك الصحية في خطاب مستقل من شركة التأمين الصحي الخاصة بك. + التصويب + العرض في شكل كود فردي + العرض في شكل كود جماعي + %s من %s + تم صرف الوصفات الطبية؟ + هل ترغب في تحديد الوصفات باعتبارها تم صرفها؟ + لم يتم الصرف + تم الصرف + يفتح الساعة %s + +49 800 277 377 7 + الخط الفني الساخن + فتح الماسح الضوئي للوصفات الطبية + الإعدادات + ملاحظة + لن يتم تشغيل هذا التغيير إلا بعد غعاد تشغيل التطبيق. + موافق + المتابعة + ساعدنا على تحسين هذا التطبيق. يتم جمع جميع بيانات الاستخدام بشكل مجهول وتعمل حصريًا على تحسين تجربة الاستخدام. + السماح بالمتابعة + في حالة حدوث عطل أو خطأ في التطبيق، يرسل لنا التطبيق معلومات عن أسباب حدوثها. كما يتم إرسال إصدار نظام التشغيل وبيانات عن الأجهزة المستخدمة. + منع أخذ لقطات الشاشة + يمنع عرض الصور المصغرة للمعاينة عند التبديل بين التطبيقات + هل تسمح للوصفات الطبية الإلكترونية بتحليل سلوك الاستخدام دون إفصاح عن الهوية؟ + حذف الأمر + عرض المزيد + عرض أقل + معلومات تقنية + سيتم حذف جميع بيانات تسجيلك في الشبكة الصحية. لكن بيانات وصفتك الطبية تظل موجودة. + سيؤدي هذا إلى حذف بيانات تسجيلك. + تسجيل الخروج + إلغاء + هل ترغب في تسجيل الخروج من التطبيق؟ + أمان بيانات وصفاتك + يُرجى الانتباه إلى أن الأشخاص الذين تشارك معهم هذا الجهاز والذين قد يمكنهم تخزين الصفات البيومترية لهم على هذا الجهاز أو الذين لديهم رقم تعريف شخصي للجهاز أو نمط مسح أو كلمة مرور، قد يمكنهم أيضًا الوصول إلى وصفاتك الطبية. + فشل الإرسال + عربة التسوق الخاصة بك جاهزة + تم استلام الرسالة + عرض كود الاستلام + فتح عربة التسوق + أظهر هذا الرمز إلى الصيدلية الخاصة بك. + كود الاستلام + لا توجد رسائل + لم تتلق أي رسائل حتى الآن + كانت الرسالة الواردة من الصيدلية للأسف فارغة. يُرجى الاتصال بالصيدلية الخاصة بك. + لم يتم إعداد بريد إلكتروني بالبرنامج + لا توجد نتائج + لم نتمكن من العثور على أي نتائج بكلمة البحث هذه. + تراخيص المصدر المفتوح + الاتصال + الاتصال بالخط الفني الساخن + المشاركة في الاستبيان + +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) + النمط + تنسيق غامق + تنسيق فاتح + اللغة + إرسال + تعقيب + بطاقة صحية + مفهوم + طلب بطاقة صحية جديدة. + يساعدك هذا التطبيق في طلب بطاقة صحية إلكترونية. ولن تتحمل هنا أي تكاليف. + يمكن الصرف قريبًا + لا يمكن لهذه الصيدلية استقبال الوصفات الإلكترونية في الوقت الحالي. + الوصفة الإلكترونية +e مفتوح حديثًا + خدمة المراسلة + إرسال + الفلتر + الفلتر المطلوب + الفرز + لا يوجد مقر متاح + مفهوم + كلمة المرور المكرر مطابقة + + + يتبقى عدد %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 + https://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 + كيف يساعدك هذا التطبيق؟ + استلم الوصفات الطبية تلقائيًا لك ولأفراد أسرتك + الاستخدام وحماية البيانات + الخطوة %s من %s + يُرجى الإدخال + متابعة + لقد تلقيت رقم التعريف الشخصي الخاص بك في خطاب من شركة التأمين الصحي الخاصة بك. + تم استلام رمز PIN + رمز PIN + تحقق من الاتصال بالإنترنت وإعدادات الوقت / التاريخ لجهازك. + للمصادقة اضغط على \"إلغاء الحظر\". + هل تم الإغلاق؟ يرجى التحقق من بيانات الدخول البيومترية الخاصة بك على هذا الجهاز. + نسيت كلمة المرور؟ يرجى حذف التطبيق ثم إعادة تثبيته. يمكنك معرفة السبب في %s + نطاق المساعدة + الكمية + المادة الفعالة + كمية المادة الفعالة + قوة المادة الفعالة + اسم الشحنة + صالح حتى + الصنف + لقاح + المكونات + المواد الفعالة: + الموافقة + تراجع + ملاحظة + هل تساعدنا في تحسين هذا التطبيق؟ + اختر كلمة مرور خاصة + يجب أن تحتوي كلمة المرور على ثمانية حروف على الأقل + قوة كلمة المرور غير كافية + قوة كلمة المرور كافية + كلمة المرور مرئية + كلمة المرور غير مرئية + القياس البيومتري + كلمة المرور + اختر تأمين الجهاز + تم اختيار تأمين الجهاز + تساعدك أسماء البروفايل هذا هذا في الاطلاع بشكل عام على الأمور عندما تريد إدارة الوصفات الطبية لعدة أشخاص. + انتظر الرد + لا توجد وصفات طبية + ليس لديك أي وصفات طبية لم تُصرف في الوقت الراهن + تحديث + تسجيل الخروج آليًا + لأسباب أمنية، يتم إنهاء الاتصال بخادم الوصفات بعد 12 ساعة. أعد الاتصال للحصول على الوصفات القائمة. + التوصيل + هل تلقيت نسخة ورقية؟ + أضف الوصفات إلى قائمتك عن طريق النقر على زر المسح في الزاوية اليمنى العليا. + المسح الضوئي للنسخة الورقية + يجب عليك تسجيل الدخول لتلقي الوصفات تلقائيا. + التسجيل + لا توجد وصفات طبية تم صرفها + يتم هنا عرض الوصفات الطبية الخاصة بك التي تم صرفها. لأسباب تتعلق بحماية البيانات ، سيتم حذف وصفاتك من خادم الوصفات بعد 100 يوم. + لا توجد وصفات طبية تم صرفها + يتم هنا عرض الوصفات الطبية الخاصة بك التي تم صرفها. أضف الوصفات الطبية عن طريق الفحص لبدء الصرف. + إدارة الأجهزة + الأجهزة المتصلة + مسجل منذ %s (هذا الجهاز) + مسجل منذ %s + حديث + أرشيف + الصرف مرة أخرى؟ + ملحوظة: الصيدلية الأولى التي تقبل الوصفة الطبية تمنع معالجتها من قبل صيدلية أخرى. + إلغاء + موافق + تم الإرسال الساعة %s + الدواء الموصوف: + + + الدواء الذي تم الحصول عليه + + + + الأدوية التي تم الحصول عليها + + + + أرسلت الوصفة %s من قبل إلى الصيدلية. صرفها مع ذلك من جديد؟ + + + + أرسلت بعض هذه الوصفات %s من قبل إلى الصيدلية.هل ترغب مع ذلكفي إرسال وصفات أخرى؟ + + أدخل رقم التعريف الشخصي لبطاقتك الصحية لتسجيل الدخول إلى خادم الوصفات الطبية. + رمز 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 وشريحة الاتصال قريب المدى على الأقل للمصادقة الآمنة باستخدام بطاقتك الصحية. + معرفة المزيد + حفظ بيانات تسجيل الدخول؟ + حفظ + عدم الحفظ + ملاحظة + أدخل رقم التعريف الشخصي لبطاقتك الصحية لتسجيل الدخول إلى خادم الوصفات. \n\n + إعداد التأمين البيومتري + لا يمكن حفظ بيانات الدخول. قم بإعداد التأمين البيومتري (مثل بصمة الإصبع) على جهازك مسبقًا. + إلغاء + الإعدادات + ملاحظة + ملاحظة + الموافقة + أمان بيانات وصفاتك + \ \"يستخدم هذا التطبيق المستشعر البيومتري الأكثر أمانًا الذي يوفره جهازك لتخزين بيانات الاعتماد الخاصة بك في منطقة آمنة من ذاكرة الجهاز. \\" + يسمح لكنظام التأمين البيومتري لبيانات الدخول الخاصة بك بفتح هذا التطبيق في المستقبل دون الحاجة إلى إدخال رقم التعريف الشخصي أو البطاقة الصحية ، وعرض الوصفات الطبية أو استدعائها أو استرداد قيمتها أو حذفها. + يُرجى الانتباه إلى أن الأشخاص الذين تشارك معهم هذا الجهاز والذين قد يمكنهم تخزين الصفات البيومترية لهم على هذا الجهاز أو الذين لديهم رقم تعريف شخصي للجهاز أو نمط مسح أو كلمة مرور، قد يمكنهم أيضًا الوصول إلى وصفاتك الطبية. + فشلت المحاولة للأسف + لم تنجح المصادقة باستخدام باستخدام تطبيق صندوق التأمين الصحي. diff --git a/android/src/main/res/values-en/strings.xml b/android/src/main/res/values-en/strings.xml index 47e19568..2ec0cbd0 100644 --- a/android/src/main/res/values-en/strings.xml +++ b/android/src/main/res/values-en/strings.xml @@ -1,471 +1,709 @@ - 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. + Information on how to take your medication and dosages + Receive messages from your pharmacy about your order + 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 + 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 + Accept Terms of Use + Accept Privacy Policy + Prescriptions + Messages + 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 + What you need: + Enter access number + Enter 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. + 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 + You have successfully logged in + Note: only prescriptions from the last 100 days are downloaded. + 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. + Select pharmacy + e.g. search for name 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. + Germany\'s modern platform for digital medicine + Write email + Open website + Welcome + Start login + Unlock + Log in + Cancel + Settings + Name unknown + Medical cards + Add card + Security + Protect your health information from unauthorised access. + Legal information + Imprint + Privacy Policy + Terms of Use + 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. + Medicine %1$d + Mark as redeemed + Mark as not redeemed + Delete from this device + 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 + 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 + Redeem the following prescriptions with binding effect at %s? + Can only be redeemed today as self-paying customer + 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. + 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 + 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? + Delete prescription + Show more + Show less + Technical information + 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. + Sending failed + Your shopping cart is ready + 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 + Take part in the survey + +49 800 277 377 7 + Find out more + 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 + 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 + Note + There may be a delay before redeemed prescriptions are moved to the archive. + OK + You need to be logged on to delete prescriptions. + Report error + Defective message received + Error message from the e-prescription app + I would like to send the following information to the service team for troubleshooting purposes. Please note that we will see your email address and any name you include. If you do not wish to transfer this information either in full or in part, please remove it from this email. All data will only be stored or processed by gematik GmbH or its contracted companies to deal with this error message. Data is deleted automatically a maximum of 180 days after the ticket has been conclusively dealt with. We will use your email address exclusively to contact you regarding this error message. If you have any questions, or require earlier deletion, you can contact the data protection representative responsible for the e-prescription system. You can find further information in the menu via the Privacy Policy entry 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: 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: + 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 + Currently open + Delivery service + Mail order + Filter + Favourite filters + 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 + No longer valid + Allow + No connection to the server + Please try again in a few minutes. + Reload + Show tokens + How would you like to secure this app? + This app uses the most secure method provided by your device, which may be a fingerprint, swipe pattern or similar. + 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 + No active tokens + 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 + Log in with medical card + In brief: log in with health insurance app + Secure log in with your new electronic medical card + Use an app from your health insurance company for activation + How would you like to log in? + You need to log in to receive prescriptions automatically and to easily redeem or reserve medications online. + Man logging in using health insurance app + Woman logging in using health insurance app + Unfortunately your device does not have NFC which is required to use this function. + Profile name + Please enter a name for the new profile. + Profile name + Profiles + What should we call you? + First name and surname + Add profile + Save + 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 indentification 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 + Order new medical card + You need a suitable NFC-enabled card to log in. We will help you order one. + Continue + 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. + This will connect to the health network. You will automatically receive new prescriptions or notifications. + 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 + https://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 + Log out + Log in + The prescription has been transferred. + Your doctor will send the prescription directly to the pharmacy. + 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 + This profile has not yet been linked to a policy number. You need to log on to the prescription server to do so. + Connected to: + Log in to the prescription server? + Conveniently receive and redeem new prescriptions + Log in + 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 + Receive collection code + 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 + + Authentication via health insurance app not completed + 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 + What does this app help with? + Automatically receive prescriptions issued to you and members of your family + Usage & Privacy Policy + Step %s of %s + Please enter + 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 + Active substance + Active substance quantity + Active substance strength + Batch description + Use by + Category + Vaccine + Composition + Active substances: + 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 + Select device security + Device security selected + Profile names help you keep track if you want to manage prescriptions for more than one person. + 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 + Sent at %s o\'clock + Prescribed medicine: + + Medicine received + Medicines received + + + 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? + + Enter the PIN of your medical card to log on to the prescription server. + 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 + Unlock card + 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 + Enter the PIN of your medical card to log on to the prescription server.\n\n + Set up biometric protection + Access data cannot be saved. Set up biometric protection (e.g. fingerprint) on your device beforehand. + Cancel + Settings + Note + 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. diff --git a/android/src/main/res/values-pl/strings.xml b/android/src/main/res/values-pl/strings.xml index d7cdc46c..e61aa36e 100644 --- a/android/src/main/res/values-pl/strings.xml +++ b/android/src/main/res/values-pl/strings.xml @@ -1,487 +1,729 @@ - 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. + Informacje na temat przyjmowania i dawkowania Twoich leków + Otrzymuj powiadomienia z apteki dotyczące Twojego zamówienia + 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 + 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 + Akceptuj warunki korzystania + Akceptuj politykę prywatności + Recepty + Powiadomienia + 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 + Co jest potrzebne: + Wprowadź numer dostępu + Wprowadź 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. + 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 + Logowanie powiodło się + Wskazówka: wczytywane są tylko recepty z ostatnich 100 dni. + 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. + Wybierz aptekę + Szukaj na przykład według nazwy lub adresu + 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ą. + Nowoczesna platforma medycyny elektronicznej w Niemczech + Napisz wiadomość e-mail + Otwórz stronę internetową + Witamy + Rozpocznij logowanie + Odblokuj + Zaloguj się + Anuluj + Ustawienia + Nieznana nazwa + Karty zdrowia + Dodaj kartę + Bezpieczeństwo + Chroń informacje o swoim zdrowiu przed dostępem osób nieuprawnionych. + Nota prawna + Stopka redakcyjna + Ochrona danych + Warunki korzystania + 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ą. + Lek %1$d + Zaznacz jako zrealizowaną + Zaznacz jako niezrealizowaną + Usuń z tego urządzenia + 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 + 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 + Czy chcesz wiążąco zrealizować następujące recepty w %s? + Możliwość zrealizowania jeszcze do dzisiaj jako płatnik indywidualny + 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. + 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 + Ś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? + Usuń receptę + Wyświetl więcej + Wyświetl mniej + Informacje techniczne + 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. + Wysyłanie nie powiodło się + Twój koszyk jest gotowy + 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ą + Weź udział w ankiecie + +49 800 277 377 7 + Dowiedz się więcej + 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 + 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 + Wskazówka + Recepty mogą zostać wyświetlone w archiwum z opóźnieniem. + OK + Musisz być zalogowany(a), aby usunąć recepty. + Zgłoś błąd + Otrzymano błędne powiadomienie + Komunikat o błędzie z aplikacji E-recepta + Chętnie przekażę niniejsze informacje zespołowi ds. wsparcia obsługi, aby można było przeprowadzić analizę błędów. Informujemy, że otrzymujemy także Twój adres e-mail oraz ewentualnie Twoje imię i nazwisko. 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ę + Przeprowadź identyfikację, 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: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: + 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 + Otwarta teraz + Usługa kurierska + Wysyłka + Filtr + Dowolne filtry + 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 + Ważność upłynęła + Zezwól + Brak połączenia z serwerem + Spróbuj ponownie za kilka minut + Załaduj ponownie + Wyświetl tokeny + Jak chcesz zabezpieczyć aplikację? + Aplikacja wykorzystuje najbezpieczniejszą metodę zabezpieczenia dostępną w Twoim urządzeniu. Takim zabezpieczeniem może być odcisk palca, wzór odblokowania itp. + 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 + Brak aktywnych tokenów + 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 + Zaloguj się za pomocą karty zdrowia + W skrócie: zaloguj się za pomocą aplikacji kasy chorych + Bezpieczne logowanie za pomocą nowej elektronicznej karty zdrowia + Skorzystaj z aplikacji swojej instytucji ubezpieczenia zdrowotnego w celu aktywacji + Jak chcesz się zalogować? + Aby automatycznie odbierać recepty i z łatwością realizować recepty lub rezerwować leki online, musisz się zalogować. + Mężczyzna loguje się za pomocą karty zdrowia + Kobieta loguje się za pomocą aplikacji kasy chorych + Niestety Twoje urządzenie nie posiada właściwości zbliżeniowych (NFC), w związku z czym nie możesz korzystać z tej funkcji. + Nazwa profilu + Podaj nazwę nowego profilu. + Nazwa profilu + Profile + Jak mamy się do Ciebie zwracać? + Imię i nazwisko + Dodaj profil + Zapisz + 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 + Zamów nową kartę zdrowia + Aby móc się zalogować, musisz mieć kartę zbliżeniową (kartę z funkcją NFC). Pomożemy Ci w jej zamówieniu. + Kontynuuj + 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. + Niniejszym nawiązujesz połączenie z siecią medyczną. Spowoduje to, że będziesz automatycznie otrzymywać nowe recepty lub wiadomości. + 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 + https://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 + Wyloguj się + Zaloguj się + Recepta została przekazana. + Twój lekarz przekażę receptę bezpośrednio do apteki. + 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ąć + Ten profil nie został jeszcze połączony z numerem ubezpieczonego. W tym celu musisz zalogować się na serwer z receptami. + Połączenie z: + Chcesz zalogować się na serwer z receptami? + Wygodnie odbieraj i realizuj nowe recepty. + Zaloguj się + 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 + Otrzymano kod odbioru + 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 + + + + Oczekuje na uwierzytelnienie przez aplikację kasy chorych + 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 + W czym pomaga Ci ta aplikacja? + Automatycznie otrzymuj recepty dla Ciebie i członków Twojej rodziny + Warunki korzystania i ochrona danych + Krok %s z %s + Wprowadź + 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 + Ilość + Substancja czynna + Ilość substancji czynnej + Stężenie substancji czynnej + Oznaczenie serii + Ważność do dnia + Kategoria + Szczepionka + Skład + Substancje czynne: + 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 + Wybierz zabezpieczenie urządzenia + Wybrano zabezpieczenie urządzenia + Nazwy profilowe pomogą Ci w zarządzaniu receptami dla kilku osób. + 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 + Wysłano o godzinie %s + Przepisany lek: + + Otrzymany lek + Otrzymane leki + + + + + 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? + + + + Wprowadź PIN swojej karty zdrowia, aby zalogować się na serwer z receptami. + 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 + Zmień PIN + 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 + Wprowadź PIN swojej karty zdrowia, aby zalogować się na serwer z receptami.\n + 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 + 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ę. diff --git a/android/src/main/res/values-ru/strings.xml b/android/src/main/res/values-ru/strings.xml index 8069bcc4..cdb3408f 100644 --- a/android/src/main/res/values-ru/strings.xml +++ b/android/src/main/res/values-ru/strings.xml @@ -1,487 +1,729 @@ - 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 + Отмена + Назад + в + Цифровизация. Оперативность. Надежность. + Информация о приеме и дозировке лекарств + Сообщения из аптеки о вашем заказе + Чтобы использовать приложение, примите условия использования и подтвердите, что вы прочитали политику конфиденциальности. Собираются только данные, необходимые для функционирования сервисов. + Я прочитал(а) и принимаю %s. + Условия использования + Политика конфиденциальности + Подтвердить + Далее + Добавить рецепты + Вы получили распечатанный рецепт? Для добавления рецептов в приложение отсканируйте код рецепта. + Понятно + ID задачи + Код доступа + Условия использования + Политика конфиденциальности + Принять условия использования + Принять политику конфиденциальности + Рецепты + Сообщения + Доступ к камере запрещен + Для использования сканера необходимо разрешить приложению доступ к камере в системных настройках. + Сфокусируйте камеру на коде рецепта + Код рецепта недействителен + Этот код рецепта уже отсканирован + + Распознан %s рецепт + Распознано %s рецепта + Распознано %s рецептов + Распознано %s рецептов + + Отмена + Подсветка камеры + Прервать сканирование кодов рецептов? + Прервать сканирование + Продолжить + Добавить карточку + Давайте начнем + Вам потребуется: + Введите номер доступа + Ввести PIN-код + Попробовать снова + Подготовьте свою электронную медицинскую карточку. + Время, необходимое для подключения вашего устройства к серверу, может варьироваться в зависимости от аппаратных характеристик устройства и скорости интернет-соединения. + Не удалось подключиться к серверу. + Введен неправильный PIN-код. + + У вас осталась еще %s попытка, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попытки, прежде чем ваша карточка будет заблокирована. + У вас осталась еще %s попыток, прежде чем ваша карточка будет заблокирована. + У вас осталось еще %s попыток, прежде чем ваша карточка будет заблокирована. + + Введен неправильный CAN + Номер доступа указан в верхнем правом углу медицинской карточки. + Отмена + Поиск карточки... + Приложите медицинскую карточку к обратной стороне устройства. + Поиск продолжается... + Медленно переместите карточку с обратной стороны устройства. + Совет + Чехол может помешать соединению через NFC. + Карточка распознана + Попытайтесь не двигать медицинскую карточку. + Медицинская карточка найдена. Не двигайте ее. + Соединение прервано + Снова приложите медицинскую карточку к обратной стороне устройства + Вы успешно вошли в систему + Внимание: загружаются только рецепты за последние 100 дней. + Версия: %s + Хэш сборки: %s + Меню отладки + Код рецепта + Отсканируйте этот код рецепта в аптеке. + Этот сводный код объединяет %s рецепта/-ов + Выкупить в аптеке + Вы находитесь в аптеке и хотите получить препарат по рецепту. + Заказать или зарезервировать + Отправьте свой рецепт в аптеку и решите, как вы хотите получить препараты. + Выбрать аптеку + например, искать по названию или адресу + Удобный поиск аптек + Разрешите определение местоположения и находите аптеки поблизости от вас + Разрешить определение местоположения + Открыто до %s + Открыто круглосуточно + Выходные данные + Издатель + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Руководитель: д-р мед. наук Маркус Лейк Дикен\nРегистрационный суд: участковый суд Берлин-Шарлоттенбург\nНомер в торговом реестре: HRB 96351\nИдентификационный номер плательщика НДС: DE241843684 + Ответственный за содержание + д-р мед. наук Маркус Лейк Дикен + Контактная информация + Указание + Мы стремимся использовать язык гендерного равенства. Если вы заметите какие-либо ошибки, мы будем рады получить от вас письмо по электронной почте. + Современная немецкая платформа цифровой медицины + Написать электронное письмо + Открыть сайт + Добро пожаловать + Начать процедуру входа + Разблокировать + Войти + Отмена + Настройки + Фамилия неизвестна + Медицинские карточки + Добавить карточку + Безопасность + Защитите информацию о своем здоровье от посторонних. + Юридическая информация + Выходные данные + Защита данных + Условия использования + У вас нет активных рецептов + Сохранить данные рецепта + Повышенная защита ваших данных с помощью отпечатка пальца или распознавания лица. + Активировать сейчас + Подробности + Оставайтесь в курсе + Отметьте этот рецепт как выкупленный, как только получите препарат. + Препарат %1$d + Отметить как выкупленный + Отметить как не выкупленный + Удалить с этого устройства + Форма выпуска + Размер упаковки + Центральный фармацевтический номер (PZN) + Указания по применению + Следуйте указаниям по применению в вашем плане приема лекарств или письменным инструкциям вашего врача по дозировке препарата. + Застрахованное лицо + Фамилия + Адрес + Дата рождения + Организация медицинского страхования / плательщик + Статус + Страховой номер + Лицо, выписавшее рецепт + Фамилия + Врач + Номер врача (LANR) + Учреждение + Фамилия + Адрес + Номер учреждения + Номер телефона + Электронная почта + Производственная травма + Дата происшествия + Номер предприятия, на котором произошел несчастный случай, или номер работодателя + Вы хотите навсегда удалить этот рецепт? + Удалить + Отмена + Поторопитесь + Этот препарат можно выкупить в аптеке и ночью без платы за обслуживание в нерабочее время. + Возможна замена препарата + Препараты-заменители допустимы. В соответствии с юридическими требованиями вашей организации медицинского страхования вам может быть выдан альтернативный препарат. + Зарезервировать + Запросить доставку курьером + Заказать отправку по почте + Примите во внимание, что за прописанные препараты может взиматься дополнительная плата. + Часы работы + Веб-сайт + Выкупить следующие рецепты в %s? + Можно выкупить в качестве самостоятельного плательщика только сегодня + Войти + Смартфон с поддержкой NFC под управлением Android 7 или выше + Активировать NFC + Активируйте функцию NFC на своем устройстве, чтобы войти в систему со своей медицинской карточкой. + Активировать + Как получить новую медицинскую карточку? + В этом вам поможет ваша организация медицинского страхования. + Как получить PIN-код? + PIN-код для медицинской карточки вы получите отдельным письмом от вашей организации медицинского страхования. + Исправить + Показать отдельные коды + Показать сводный код + %s из %s + Рецепты выкуплены? + Отметить рецепты как выкупленные? + Не выкуплены + Выкуплены + Откроется в %s + +49 800 277 377 7 + Техническая горячая линия + Открыть сканер рецептов + Настройки + Указание + Это изменение вступит в силу только при следующем запуске приложения. + OK + Отслеживание + Помогите нам сделать это приложение лучше. Все данные об использовании собираются анонимно и служат исключительно для улучшения пользовательского опыта. + Разрешить отслеживание + В случае сбоя или ошибки в приложении приложение отправляет нам информацию о причинах. Кроме того, отправляются данные о версии операционной системы и об используемом оборудовании. + Отключить скриншоты + Скрывать предпросмотр при смене приложения + Разрешаете ли вы приложению E-Rezept анонимно анализировать поведение пользователя? + Удалить назначение + Показать больше + Показать меньше + Техническая информация + Удаляются все данные доступа к сети здравоохранения. Данные ваших рецептов сохраняются. + Ваши данные доступа будут удалены. + Выйти + Отмена + Вы хотите выйти из приложения? + Защита данных ваших рецептов + Примите во внимание: если этим устройством вместе с вами пользуются другие люди, которые сохранили свои биометрические параметры на устройстве или знают его PIN-код, графический ключ или пароль, они могут получить доступ к вашим рецептам. + Не удалось отправить + Ваша корзина готова + Получено сообщение + Показать код самовывоза + Открыть корзину + Покажите этот код в аптеке. + Код самовывоза + Нет сообщений + Вы еще не получили ни одного сообщения + К сожалению, сообщение из вашей аптеки было пустым. Обратитесь в свою аптеку. + Приложение электронной почты не настроено + Результаты не найдены + По данному поисковому запросу результаты не найдены. + Лицензии на ПО с открытым исходным кодом + Контактная информация + Позвонить на горячую линию + Принять участие в опросе + +49 800 277 377 7 + Узнать больше + Анализировать пользовательские потоки в приложении %s, чтобы сделать его более удобным. + Отправлять разработчикам информацию о сбоях и сообщения об ошибках %s. + Оперативно выявлять типы ошибок, чтобы оптимизировать работу горячей линии. + Я хочу помочь в работе по улучшению этого приложения + Вы можете в любое время изменить это решение в системных настройках. + анонимно + Эти данные включают в себя информацию об аппаратном и программном обеспечении вашего телефона, настройках приложения E-Rezept и объеме использования, но никогда не включают информацию о вас или вашем здоровье. + Обработчики данных предоставляют информацию только компании gematik GmbH и удаляют ее не позднее чем через 180 дней. Вы можете в любое время деактивировать анализ в меню приложения. + Эти данные позволяют нам понять, какие функции часто используются, и оптимизировать их. Кроме того, мы оцениваем, на протяжении какого времени требуется поддержка старого оборудования и когда, например, мы можем включить в системные требования более новую версию операционной системы, чтобы изменения затронули как можно меньше пользователей. + Улучшить приложение + Отказаться + Анонимный анализ остается деактивирован + %s Спасибо за вашу поддержку! + Заказать или зарезервировать + Указание + При перемещении выкупленных рецептов в архив возможны задержки. + OK + Для удаления рецептов необходимо войти в систему. + Сообщить об ошибке + Получено некорректное сообщение + Сообщение об ошибке из приложения E-Rezept + Вы отправляете нам эту информацию для устранения неполадок. Обратите внимание, что вместе с ней будут переданы также ваш адрес электронной почты и, возможно, ваше имя, если оно указано в поле отправителя. Если вы не хотите передавать эту информацию полностью или частично, удалите ее из этого письма. Компания gematik GmbH или ее подрядчик хранит и обрабатывает все данные только в целях обработки этого сообщения об ошибке. Удаление происходит автоматически, не позднее чем через 180 дней после обработки заявки. Мы используем ваш адрес электронной почты только для связи с вами по поводу этого сообщения об ошибке. Чтобы задать вопросы или обратиться с просьбой о досрочном удалении данных, вы можете в любое время связаться с ответственным по защите данных системы E-Rezept. Дополнительную информацию см. в меню приложения E-Rezept в разделе о защите данных. + Войти + Пройдите идентификацию для загрузки рецептов. + Выкуплен %s + Вы получили альтернативный препарат + Следуйте указаниям по применению в вашем плане приема лекарств или письменным инструкциям вашего врача по дозировке препарата. + Информация для аптек: это приложение получает контактные данные и информацию об аптеках с сайта mein-apothekenportal.de Немецкой ассоциации фармацевтов Deutscher Apothekerverband e.V. Вы обнаружили ошибку или хотите исправить данные? + Узнать больше + Аптеки + К сожалению, выполнить не удалось \uD83D\uDE15 + Попробуйте еще раз. + Ввести пароль + Далее + Вспомогательные инструменты + Изменение масштаба + Изменение размеров содержимого в окне приложения сведением или разведением пальцев на экране. + Указание + Пароль + Защитите свои данные, установив собственный пароль. + Пароль + Сохранить + Показать пароль + Повторить пароль + Рекомендации: %s + Написать электронное письмо + При отправке сообщения будет передана следующая информация об используемом аппаратном обеспечении и операционной системе: + Операционная система + Android %s (версия разработки %s) (последнее обновление системы безопасности %s) + Модель + %s %s (кодовое обозначение %s) + Режим + Темная тема + Светлая тема + Язык + Отправить + Обратная связь + Медицинская карточка + Понятно + Подать заявку на новую медицинскую карточку + Это приложение поможет вам подать заявку на новую медицинскую карточку. Оплата не требуется. + Выкуп будет возможен в ближайшее время + Эта аптека пока не может принимать электронные рецепты. + E-Rezept + Сейчас открыто + Курьерская доставка + Отправка + Фильтр + Предпочитаемые фильтры + Фильтры + Местоположение недоступно + Понятно + Пароли совпадают + + Можно выкупить в качестве самостоятельного плательщика еще в течение %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 для использования этой функции. + Имя профиля + Введите имя нового профиля. + Имя профиля + Профили + Как к вам обращаться? + Имя и фамилия + Добавить профиль + Сохранить + Медицинская карточка + Обратиться в организацию медицинского страхования + Для регистрации в этом приложении вам необходима медицинская карточка, поддерживающая функции NFC, и PIN к ней. + Вы можете бесплатно получить ее в своей организации медицинского страхования. Для этого вам потребуется подтвердить свою личность официальным документом. + Как определить, поддерживает ли медицинская карточка функции NFC + Выбрать организацию медицинского страхования + Не выбрано + Что вы хотели бы заказать? + Обратиться к ней через это приложение нельзя + Свяжитесь со своей страховой организацией по обычным каналам. + Медицинская карточка и PIN + Только PIN + Обратитесь в свою организацию медицинского страхования + Вход в приложение E-Rezept + Заказать новую медицинскую карточку + Для регистрации требуется соответствующая карточка с NFC. Мы поможем вам ее заказать. + Продолжить + Поле имени не может быть пустым. + Профиль с таким именем уже существует. + Профиль + %s выбран + Фоновый цвет + Весенний серый + Росянка + Это! Розовый! + Дерево + Синяя луна сентябрь + Вход не выполнен + Соединение установлено + Дата последнего соединения %s + Удалить профиль? + Все данные профиля на этом устройстве будут удалены. Ваши рецепты в сети здравоохранения сохранятся. + Удалить + Отмена + Удалить профиль + Вы собираетесь удалить последний профиль. + Приложению необходим как минимум один профиль. Введите имя нового профиля. + Ошибка 20 10 76831 + Не удалось получить доступ к списку медицинских карточек. Попытайтесь повторить позднее. + Будет установлено соединение с сетью здравоохранения. Благодаря этому вы будете автоматически получать новые рецепты или сообщения. + На национальном портале здравоохранения вы найдете проверенную специалистами информацию о болезнях, кодах МКБ, профилактике и уходе за больными. + Перейти по адресу gesund.bund.de + https://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 + Каким образом это приложение помогает вам? + Автоматически получайте рецепты, выписанные вам и членам вашей семьи + Использование и защита данных + Шаг %s из %s + Введите + Далее + PIN-код вы получили в письме от организации медицинского страхования. + PIN-код не получен + PIN-код + Проверьте соединение с Интернетом и настройки времени/даты на вашем устройстве. + Для аутентификации нажмите \"Разблокировать\". + Блокировка? Проверьте свои биометрические данные доступа на этом устройстве. + Забыли пароль? Удалите приложение и затем установите его заново. Причины мы объясняем в %s. + Раздел справки + Количество + Действующее вещество + Количество действующего вещества + Эффективность действующего вещества + Обозначение партии + Годен до + Категория + Вакцина + Состав + Действующие вещества: + Принять + Отменить + Указание + Поможете нам сделать это приложение лучше? + Установить собственный пароль + Пароль должен состоять как минимум из восьми символов + Надежность пароля недостаточная + Надежность пароля достаточная + Показывать пароль + Не показывать пароль + Биометрия + Пароль + Выбрать функцию защиты устройства + Функция защиты устройства выбрана + Имена профилей помогут вам сориентироваться, если вы планируете управлять рецептами для нескольких человек. + Ожидание ответа + Нет рецептов + В настоящее время у вас нет рецептов, которые можно выкупить. + Обновить + Автоматический выход из системы + В целях безопасности соединение с сервером рецептов разрывается через 12 часов. Установите соединение заново, чтобы запросить актуальные рецепты. + Установить соединение + Вы получили распечатку? + Добавьте рецепты в свой список, нажав кнопку сканирования в правом верхнем углу. + Отсканировать распечатку + Чтобы получать рецепты автоматически, необходимо войти в систему. + Войти + Нет выкупленных рецептов + Здесь отображаются ваши выкупленные рецепты. В целях защиты данных ваши рецепты удаляются с сервера рецептов через 100 дней. + Нет выкупленных рецептов + Здесь отображаются ваши выкупленные рецепты. Отсканируйте новые рецепты, чтобы начать их выкуп. + Управление устройствами + Привязанные устройства + Дата регистрации %s (данное устройство) + Дата регистрации %s + Актуальные + Архив + Выкупить заново? + Указание: аптека, принимающая рецепт первой, блокирует его обработку другими аптеками. + Отмена + OK + Отправлено в %s + Назначенный препарат: + + Полученный препарат + Полученные препараты + Полученные препараты + Полученные препараты + + + Вы уже отправили в аптеку %s рецепт. Все равно выкупить заново? + Вы уже отправили в аптеку %s рецепта. Все равно выкупить заново? + Вы уже отправили в аптеку %s рецептов. Все равно выкупить заново? + Вы уже отправили в аптеку некоторые из этих рецептов. Все равно выкупить заново? + + Введите PIN-код своей медицинской карточки, чтобы войти на сервер рецептов. + PIN-код + Введите PIN-код (карточки здоровья) + Далее + Аутентификация + Привязанные устройства + Удалить устройство? + Отмена + Удалить + Удалить это устройство? + Вы хотите удалить %s? + Если вы удалите %s, не позднее чем через 12 часов соединение с сервером рецептов будет разорвано на длительное время. + Устройства загружаются... + Нет устройств + К этой медицинской карточке не привязано ни одно устройство. + Попробовать снова + Ох-ох:-( + Не удалось загрузить список устройств. + Нет соединения + Соединение с Интернетом отсутствует. + Препараты и перевязочные средства + Наркотические средства + Отпуск медикаментов, выдаваемых по рецепту, согласно § 4 постановления о рецептах на лекарственные препараты + Вам требуется помощь? + Мы подобрали ряд советов по решению наиболее часто встречающихся проблем. + Показать советы по привязке + Отсканирован: %s + Отсканированный рецепт + Разблокировать + Карточка заблокирована + PIN-код был введен неверно три раза, поэтому ваша карточка заблокирована в целях безопасности. + Разблокировать карточку + Ввести PUK-код + Вместе с PIN-кодом вы получили от своей страховой организации 8-значный PUK-код. + Выбрать новый PIN-код + Новый индивидуальный идентификационный номер (PIN-код) вы можете выбрать самостоятельно (от 6 до 8 символов). + Запомнили PIN-код? + Запишите свой PIN-код и сохраните записку в надежном месте. + Отмена + Введен неправильный PUK-код. + OK + Деблокировка невозможна + Вы достигли максимального количества операций деблокировки с помощью этого PUK-кода либо повторно ввели неправильный код. Обратитесь в свою страховую организацию. + Вы можете использовать PUK-код максимум для 10 операций деблокировки. + Карточка разблокирована + Изменить PIN-код + Вам потребуется: + Ваша медицинская карточка + 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. + Узнать больше + Сохранить данные доступа? + Сохранить + Не сохранять + Указание + Введите PIN-код вашей медицинской карточки, чтобы войти на сервер рецептов.\n\n + Настроить биометрическую защиту + Сохранение данных доступа невозможно. Сначала настройте на своем устройстве биометрическую защиту (например, с помощью отпечатка пальца). + Отмена + Настройки + Указание + Указание + Принять + Защита данных ваших рецептов + \"Это приложение использует самый надежный биометрический датчик, имеющийся на вашем устройстве, для обеспечения безопасности ваших данных доступа в защищенном разделе памяти устройства. \" + Биометрическая защита ваших данных доступа позволяет в будущем без помощи медицинской карточки и ввода PIN-кода открывать это приложение, а также просматривать, запрашивать, выкупать и удалять рецепты. + Примите во внимание: если этим устройством вместе с вами пользуются другие люди, которые сохранили свои биометрические параметры на устройстве или знают его PIN-код, графический ключ или пароль, они могут получить доступ к вашим рецептам. + К сожалению, выполнить не удалось + Аутентификация с помощью приложения организации медицинского страхования не пройдена. diff --git a/android/src/main/res/values-tr/strings.xml b/android/src/main/res/values-tr/strings.xml index 77cd5c4f..81f97ccc 100644 --- a/android/src/main/res/values-tr/strings.xml +++ b/android/src/main/res/values-tr/strings.xml @@ -1,471 +1,709 @@ - 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. + İlaçlarınızın alınması ve dozajları hakkında bilgi + Eczanenizden siparişinizle ilgili bildirimler alın + 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 + 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ı + Kullanım koşullarını kabul et + Veri koruma politikasını kabul et + Reçeteler + Bildirimler + 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 + İhtiyacınız olan şey: + Erişim numarasını girin + PIN girin + 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. + 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. + Başarıyla oturum açtınız + Not: Yalnızca son 100 güne ait reçeteler indirilir. + 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. + Eczaneyi seç + örneğin ada veya adrese göre arama + 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. + 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 + Ayarlar + Adı bilinmiyor + Sağlık kartları + Kart ekle + Güvenlik + Sağlık bilgilerinizi yetkisiz erişime karşı koruyun. + Yasal + Künye + Veri koruma + Kullanım koşulları + 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. + İlaç %1$d + Kullanıldı olarak işaretle + Kullanılmadı olarak işaretle + Bu cihazdan sil + İ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 + 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 + %s\'da bulunan reçeteleri bağlayıcı olarak kullanmak istiyor musunuz? + Sacede bugün ve sadece kendiniz ödeyerek kullanabilirsiniz + 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. + 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 + İ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? + Yönergeyi sil + Daha fazla göster + Daha az göster + Teknik bilgiler + 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. + Gönderim başarısız oldu + Alışveriş sepetiniz hazır + 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 + Ankete katıl + +49 800 277 377 7 + Daha fazla bilgi + 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 + 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 + Not + Kullanılan reçetlerin Arşiv alanında görüntülenmesi biraz zaman alabilir. + Tamam + Reçete silmek için oturum açmanız gerekir. + Hatayı bildir + Hatalı bildirim alındı + 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ç + Reçeteyi 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: 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: + İş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 + Şu an açık + Kurye hizmeti + Kargo + Filtre + Favori filtreler + 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 + Artık geçerli değil + İ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? + Bu uygulama cihazınızın kullanıma sunulan en güvenli metotu kullanıyor. Bu bir parmak izi, bir silme deseni veya benzeri olabilir. + 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ı + Herhangi bir aktif token yok + 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 + Sağlık kartı ile oturum açın + Yakında: Sağlık sigortası uygulama ile oturum açma + Yeni elektronik sağlık kartınızla güvenli oturum açma + Sağlık sigortanızın bir uygulamasını kullanarak etkinleştirin + Nasıl oturum açmak istiyor musunuz? + Oturum açarak otomatik bir şekilde reçeteleri alır ve kolayca çevrim içi ilaçları kullanabilir veya rezerve edebilisiniz. + Bir erkek sağlık kartı ile oturum açıyor + Bir kadın sağlık sigortası uygulaması ile oturum açıyor + Cihazınız bu fonksiyonu kullanmak için maalesef NFC özellikliğne sahip değil. + Profilin adı + Yeni profil için bir ad girin. + Profil adı + Profiller + Size nasıl hitap etmemizi istersiniz? + Ad ve soyad + Profil ekle + Kaydet + 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 + Yeni sağlık kartı için sipariş ver + Oturum açmak için NFC + Devam et + 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. + Bununla sağlık ağına bağlantı kurarsınız. Bu şekilde yeni reçeteleri veya bildirimleri otomatik olarak alırsınız. + 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ç + https://gesund.bund.de/ + 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 + Çıkış yap + Giriş yap + Reçete aktarıldı. + Reçeteniz doktorunuz tarafından doğrudan ezcaneye iletilir. + 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 + Bu profil bir sigortalı numarası ile bağlantılı. Bunun için reçete sunucusunda oturum açmanız gerekir. + Şununla bağlantılı: + Reçete sunucusunda oturum açılsın mı? + Konforlu bir şekilde yeni reçeteleri alın ve kullanın. + Oturum aç + 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 + Teslim alma kodu alındı + 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 sigortası uygulaması üzerinden doğrulama bekliyor + 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 + Bu uygulama size hangi konuda yardımcı olur? + Kendiniz ve aile üyeleriniz için yazılmış reçetelerinizi otomatik olarak alın + Kullanım ve veri koruma + Adım %s/%s + Lütfen girin + İ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ı + Miktar + Etken madde + Etken madde miktarı + Etken madde güçlüğü + Parti adı + Son kullanma tarihi: + Kategori + Aşı + Birleşim + Etken madde: + 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 + Cihaz yedeklemeyi seç + Cihaz yedekleme seçildi + Eğer birkaç kişi için reçeteleri yönetiyorsanız profil adları genel bakışı kazanmak için yardımcı olur. + 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 tahih ve saatinde gönderildi + Reçetelenen ilaç: + + Alınan ilaç + Alınan ilaçlar + + + %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? + + Reçete sunucusunda oturum açmak için sağlık kartınızın PIN\'ini girin. + 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ı + Kartın blokesini kaldırın + Ş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 + Reçete sunucusunda oturum açmak için sağlık kartınızın PIN\'ini girin.\n\n + 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 + 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ı. 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..dfa640e2 --- /dev/null +++ b/android/src/main/res/values-uk/strings.xml @@ -0,0 +1,729 @@ + + + E-Rezept + Ok + Скасувати + Назад + о + Цифрово. Швидко. Надійно. + Інформація про приймання та дозування ліків + Отримуйте повідомлення з вашої аптеки про своє замовлення + Щоб використовувати застосунок, погодьтеся з умовами використання та підтвердьте, що ви прочитали Політику конфіденційності. Збираються лише дані, необхідні для функціонування сервісів. + Я прочитав/прочитала %s приймаю її. + Умови використання + Політика конфіденційності + Підтвердити + Далі + Додати рецепти + Ви отримали видрук рецепта? Додайте рецепти до застосунку, відсканувавши відповідний код рецепта. + Зрозуміло + Ідентифікатор завдання + Код доступу + Умови використання + Політика конфіденційності + Прийняти умови використання + Прийміть Політику конфіденційності + Рецепти + Повідомлення + Відмовлено в доступі до камери + Щоб скористатися сканером, потрібно дозволити застосунку доступ до камери в системних налаштуваннях. + Сфокусуйте камеру на коді рецепта + Це недійсний код рецепта + Цей код рецепта уже відскановано + + Розпізнано один рецепт + Розпізнано %s рецепти + Розпізнано %s рецептів + + + Скасувати + Світло камери + Скасувати сканування кодів рецептів? + Скасувати сканування + Продовжити + Додати картку + Почнімо + Вам потрібно: + Введіть номер доступу + Ввести PIN-код + Спробуйте ще раз + Тримайте свою електронну картку здоров’я напоготові. + Вашому пристрою може знадобитися різний час для підключення до сервера залежно від вашого апаратного забезпечення та швидкості Інтернету. + Помилка з’єднання з сервером + Введено неправильний PIN-код. + + У вас ще одна спроба, перш ніж буде заблоковано картку + У вас ще %s спроби, перш ніж буде заблоковано картку + У вас ще %s спроб, перш ніж буде заблоковано картку + + + Введено неправильний CAN + Ви знайдете номер доступу у верхньому правому куті своєї картки здоров\'я. + Скасувати + Пошук картки... + Тримайте картку здоров’я на задній панелі пристрою. + Усе ще триває пошук... + Повільно переміщуйте картку на задній панелі пристрою. + Порада + Корпуси пристрою можуть ускладнити з\'єднання через NFC. + Картку розпізнано + Намагайтеся не рухати карту здоров’я. + Картку здоров’я знайдено. Не рухайтеся. + Підключення скасовано + Ще раз прикладіть картку здоров’я до задньої панелі пристрою. + Ви успішно увійшли в систему + Примітка: завантажуватимуться лише рецепти за останні 100 днів. + Версія: %s + Хеш збірки: %s + Меню налагодження + Код рецепта + Відскануйте цей код рецепта у своїй аптеці. + Цей збірний код об’єднує %s рецептів + Погасити в аптеці + Ви стоїте в аптеці й хочете отримати лікарства по рецепту. + Замовити або зарезервувати + Відправте свій рецепт в аптеку і вкажіть, як ви хочете отримати ліки. + Вибрати аптеку + напр., пошук за прізвищем або адресою + Легкий пошук аптек + Поділіться місцем розташування та знайдіть аптеки у вашому регіоні + Поділитися місцем розташування + Відчинено до %s + Відчинено без перерв + Вихідні дані + Видавець + gematik GmbH\nFriedrichstraße 136\n10117 Berlin + Керівний директор: д-р. мед. н Маркус Лейк Дікен (Markus Leyck Dieken)\n Реєстраційний суд: окружний суд Берлін-Шарлоттенбург\n Номер торгового реєстру: HRB 96351\nІН платника ПДВ: DE241843684 + Відповідальний за контент + Доктор мед. н. Маркус Лейк Дікен + Контакт + Указівка + Ми намагаємося використовувати гендерно нейтральну мову. Якщо ви помітили помилки, ми будемо раді вашим повідомленням електронною поштою. + Сучасна німецька платформа для цифрової медицини + Написати електронне повідомлення + Відкрити вебсайт + Вітаємо! + Розпочати реєстрацію + Розблокувати + Вхід + Скасувати + Налаштування + Ім\'я не відоме + Картки здоров\'я + Додати картку + Безпека + Захистіть інформацію про своє здоров’я від несанкціонованого доступу. + Правові питання + Вихідні дані + Захист даних + Умови використання + У вас немає поточних рецептів + Захистити дані рецептів + Покращений захист ваших даних за допомогою сканування відбитків пальців або обличчя. + Активувати зараз + Деталі + Зберігайте контроль + Позначте цей рецепт як погашений після того, як ви отримаєте ліки. + Препарат %1$d + Позначити як погашено + Позначити як не погашено + Видалити з цього пристрою + Лікарська форма + Розмір пакування + Загальнонімецький ідентифікатор лікарських засобів (PZN) + Вказівки щодо приймання + Дотримуйтесь вказівок щодо приймання у вашому плані лікування або письмових інструкцій щодо дозування від вашого лікаря. + Застрахована особа + Прізвище + Адреса + Дата народження + Медична страхова компанія / Носій витрат + Статус + Страховий номер + Особа, яка призначає рецепт + Прізвище + Профільний лікар + Номер лікаря (LANR) + Установа + Прізвище + Адреса + Номер виробничого майданчика + Номер телефону + Ел. пошта + Нещасний випадок на виробництві + День нещасного випадку + Номер місця нещасного випадку або номер роботодавця + Видалити цей рецепт безповоротно? + Видалити + Скасувати + Тут потрібно поспішати + Цей препарат можна навіть вночі придбати в аптеці без плати за екстрене обслуговування. + Можливий замінник + Замінники дозволені. Відповідно до законодавчих вимог вашої лікарняної каси вам можуть надати альтернативний медикамент. + Зарезервувати з зобов’язанням придбання + Зробити запит кур\'єрської служби + Доставити поштою + Зауважте, що за виписані ліки також може нараховуватися доплата. + Графік роботи: + Вебсайт + Бажаєте викупити наступні рецепти в %s, що матиме обов’язковий характер? + Ще тільки сьогодні рецепт можна погасити, якщо ви оплачуєте самостійно. + Вхід + Смартфон із підтримкою NFC з ОС Android 7 і вище + Активувати NFC + Активуйте функцію NFC на своєму пристрої, щоб увійти за допомогою своєї картки здоров’я. + Активувати + Як отримати нову медичну картку здоров’я? + Тут вам допоможе ваша медична страховка. + Як отримати PIN-код? + Ви отримаєте PIN-код для вашої картки здоров’я від своєї компанії медичного страхування в окремому листі. + Відкоректувати + Показати як окремі коди + Показати як збірний код + %s з %s + Рецепти погашено? + Позначити рецепти як погашені? + Не погашено + Погашено + Відчиняється о %s + +49 800 277 377 7 + Технічна гаряча лінія + Відкрити сканер для рецептів + Налаштування + Указівка + Ця зміна набуде чинності лише після перезапуску застосунку. + Ok + Відстеження + Допоможіть нам покращити цей застосунок. Усі дані користувача збираються анонімно і використовуються виключно для покращення користувацького досвіду. + Дозволити відстеження + У разі збою або помилки застосунок надішле нам вказівки щодо причин. Крім того, надсилається версія операційної системи та інформація про використовуване обладнання. + Придушити скріншоти + Запобігає відображенню заставки під час переходу з одного застосунку до іншого + Чи дозволяєте ви застосунку E-Rezept анонімно аналізувати поведінку користувача? + Видалити розпорядження + Показати більше + Показати менше + Технічна інформація + Усі дані доступу до мережі охорони здоров’я будуть видалені. Ваші дані про рецепти залишаються. + Так будуть видалені дані доступу. + Вийти з системи + Скасувати + Хочете вийти з застосунку? + Безпека ваших даних рецепта + Майте на увазі, що особи, з якими ви, можливо, спільно користуєтеся цим пристроєм і чиї біометричні функції можуть зберігатися на цьому пристрої, або які мають PIN-код пристрою, графічний ключ або пароль, також матимуть доступ до ваших рецептів. + Помилка відправлення + Ваш кошик для покупок готовий + Отримувати сповіщення + Показати код для самовивозу + Відкрити кошик + Покажіть цей код у своїй аптеці. + Код для самовивозу + Повідомлення відсутні + Ви ще не отримали жодних сповіщень + На жаль, повідомлення з вашої аптеки було порожнім. Зверніться до своєї аптеки. + Не налаштований жодний поштовий клієнт + Немає результатів + За цим словом пошуку не знайдено жодних результатів. + Ліцензії з відкритим кодом + Контакт + Зателефонувати на технічну гарячу лінію + Взяти участь в опитуванні + +49 800 277 377 7 + Детальніше + Аналізуйте потоки користувачів у застосунку %s, щоб покращити зручність використання. + Відправити розробникам повідомлення про збої та помилки %s. + Завчасно розпізнавати характеристики помилок для покращення технічної гарячої лінії. + Я хочу допомогти покращити цей застосунок + В налаштуваннях системи ви можете змінити це рішення у будь-який час. + анонімно + Це включає інформацію про апаратне та програмне забезпечення на вашому телефоні, налаштування застосунку E-Rezept та обсяг використання, однак у жодному разі не дані про вас особисто чи ваше здоров’я. + Ці дані надаються тільки gematik GmbH компанією, яка обробляє дані, та видаляються не пізніше ніж через 180 днів. Аналіз можна деактивувати в меню застосунку в будь-який час. + Ці дані дозволяють нам зрозуміти, які функції часто використовуються, і покращити їх. Крім того, ми можемо оцінити, як довго має підтримуватися старіша технологія і коли ми можемо, наприклад, зробити нову версію операційної системи обов’язковою, щоб це не зачепило (занадто багато) користувачів. + Покращити застосунок + Відхилити + Анонімний аналіз залишається деактивованим + %s Дякуємо за Вашу підтримку! + Замовити або зарезервувати + Указівка + Перед тим, як погашені рецепти будуть переміщені в архів, може виникнути затримка. + Ok + Щоб видалити рецепти, ви повинні увійти до системи. + Повідомити про помилку + Отримано помилкове сповіщення + Повідомлення про помилку з застосунку E-Rezept + Вказану нижче інформацію я спрямую на адресу команди служби підтримки з метою пошуку несправностей. Майте на увазі, що ми дізнаємося про вашу адресу електронної пошти та, якщо можливо, ваше ім’я, якщо ви конфігурували його як відправника електронного листа. Якщо ви не хочете передавати цю інформацію повністю або частково, видаліть її з цього електронного листа. Усі дані зберігаються та обробляються компанією gematik GmbH або уповноваженими нею компаніями лише для обробки цього повідомлення про помилку. Видалення відбувається автоматично, не пізніше ніж через 180 днів після завершення робіт за талоном. Ми використовуємо вашу адресу електронної пошти лише для зв’язку з вами щодо цього повідомлення про помилку. Ви можете в будь-який час зв’язатися з фахівцем із захисту даних системи електронних рецептів, якщо у вас виникли запитання або ви хочете видалити свої дані передчасно. Більше інформації можна знайти в застосунку E-Rezept в меню у пункті про захист персональних даних. + Вхід + Щоб завантажити рецепти, ідентифікуйте себе. + Дата погашення: %s + Ви отримали замінник препарату + Дотримуйтесь вказівок щодо приймання у вашому плані лікування або письмових інструкцій щодо дозування від вашого лікаря. + Примітка для аптек: ми отримуємо контактні дані та інформацію про аптеки від mein-apothekenportal.de Німецької аптечної асоціації Deutsche Apothekenverband e.V. Ви виявили помилку чи хотіли б виправити дані? + Детальніше + Аптеки + На жаль, спроба невдала \uD83D\uDE15 + Повторіть спробуй + Введіть пароль + Далі + Довідки з керування + Масштабувати + Дозволяє збільшувати масштабування застосунку зведенням або розведенням пальців (зведення для масштабування). + Указівка + Пароль: + Захистіть свої дані паролем на свій вибір. + Пароль + Зберегти + Відобразити пароль + Повторно введіть пароль + Рекомендації: %s + Написати електронне повідомлення + Під час надсилання своїх повідомлень буде передаватися вказана нижче інформація про обладнання та операційну систему, що використовується: + Операційна система + Android %s (версія розробника %s) (останнє оновлення безпеки %s) + Модель + %s %s (кодове ім\'я %s) + Режим + Темний дизайн + Світлий дизайн + Мова + Надіслати + Відгук + Картка здоров\'я + Зрозуміло + Подати заяву на нову картку здоров’я + Цей застосунок допоможе вам подати заявку на отримання нової електронної картки здоров\'я. Ви не понесете жодних витрат. + Погасити якомога швидше + Наразі ця аптека ще не може приймати електронні рецепти. + E-Rezept + Наразі відчинено + Кур\'єрська служба + Розсилання + Фільтр + Часто вживані фільтри + Фільтрувати + Жодна локація не доступна + Зрозуміло + Паролі не збігаються + + Залишається ще один день, щоб погасити рецепт, якщо ви оплачуєте самостійно. + Залишається ще %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 для використання цієї функції. + Ім\'я профілю + Введіть ім\'я для нового профілю. + Ім\'я профілю + Профілі + Як нам вас називати? + ПІБ + Додати профіль + Зберегти + Картка здоров\'я + Зверніться до медичної страхової компанії + Щоб увійти в цей застосунок, вам потрібна картка здоров\'я з підтримкою NFC та відповідний PIN-код. + Її ви отримаєте безкоштовно від своєї медичної страхової компанії. Для цього ви повинні ідентифікувати себе за допомогою офіційного документа, що посвідчує особу. + Так можна розпізнати картку здоров\'я з підтримкою NFC + Вибрати медичну страхову компанію + Вибір відсутній + Бажаєте подати заявку? + Зв\'язатися через цей застосунок не можливо + Використовуйте звичайні канали, щоб зв\'язатися зі своєю страховою компанією. + Картка здоров’я та PIN + Тільки PIN + Зв\'яжіться зі своєю медичною страховою компанією + Вхід у застосунок E-Rezept + Замовити нову картку здоров’я + Для реєстрації потрібна відповідна карта з підтримкою NFC. Ми підтримуємо вас у процесі замовленням. + Продовжити + Поле прізвища не може бути порожнім. + Профіль із введеним іменем уже існує. + Профіль + Вибрано %s + Колір фону + Весняний сірий + Росичка + Це! Рожевого! Кольору! + Дерево + Синій місяць вересень + Не в системі + З\'єднано + Дата останнього підключення: %s + Видалити профіль? + Таким чином буде видалено всі дані профілю на цьому пристрої. Ваші рецепти в мережі охорони здоров’я залишаться неушкодженими. + Видалити + Скасувати + Видалити профіль + Ви хочете видалити останній профіль. + Для застосунку потрібен принаймні один профіль. Введіть ім\'я для нового профілю. + Помилка 20 10 76831 + Не вдалося отримати доступ до каталогу карток здоров\'я. Спробуйте ще раз. + Цим ви встановлюєте зв\'язок з мережею охорони здоров’я. Так ви отримуватимете автоматично нові рецепти чи повідомлення. + На Національному порталі охорони здоров’я можна знайти фахово перевірену інформацію про хвороби, коди МКХ та питання щодо профілактики та догляду. + Відкрити gesund.bund.de + https://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 + У чому вам допомагає цей застосунок? + Отримуйте автоматично виписані рецепти для вас і членів вашої родини + Користування і захист даних + Крок %s з %s + Введіть + Далі + Ви отримали свій PIN-код у листі від вашої медичної страхової компанії. + PIN-код не отримано + PIN + Перевірте підключення до Інтернету та налаштування часу/дати на вашому пристрої. + Для автентифікації натисніть \"Розблокувати\". + Заблоковано? Перевірте свої біометричні облікові дані на цьому пристрої. + Забули пароль? Видаліть застосунок, а потім повторно встановіть його. Чому це так, ви можете дізнатися в %s + Довідка + Кількість + Активна речовина + Кількість активної речовини + Сила активної речовини + Позначення партії + Використовувати до + Категорія + Вакцина + Склад + Активні речовини: + Прийняти + Скасувати + Указівка + Допоможете нам покращити цей застосунок? + Вибрати власний пароль + Пароль повинен містити не менше восьми символів + Надійність пароля не достатня + Надійність пароля достатня + Пароль видимий. + Пароль не видимий. + Біометрія + Пароль + Вибрати захист пристрою + Захист пристрою вибрано + Імена профілів допоможуть вам контролювати процес керування рецептами для кількох людей. + Зачекайте на відповідь + Кожних рецептів + У вас наразі немає рецептів, які можна було б погасити + Оновити + Автоматичний вихід з системи + З міркувань безпеки з’єднання з сервером рецептів припиняється через 12 годин. Підключіться знову, щоб отримати поточні рецепти. + Підключити + Ви отримали видрук? + Додайте рецепти до свого списку, натиснувши кнопку сканування у верхньому правому куті. + Зісканувати видрук + Щоб автоматично отримувати рецепти, ви повинні увійти в систему. + Вхід + Непогашених рецептів немає + Тут відображаються ваші погашені рецепти. З міркувань захисту персональних даних ваші рецепти будуть видалені з сервера рецептів через 100 днів. + Непогашених рецептів немає + Тут відображаються ваші погашені рецепти. Додайте рецепти за допомогою сканування, щоб почати погашення рецепта. + Керування пристроями + Підключені пристрої + Зареєстровано з %s (цей пристрій) + Зареєстровано з %s + Актуальний + Архів + Погасити повторно? + Примітка. Аптека, яка першою приймає рецепт, блокує його обробку в іншій аптеці. + Скасувати + Ok + Час відправлення: %s + Призначений медикамент: + + Отриманий медикамент + Отримані медикаменти + Отримані медикаменти + Отримані медикаменти + + + Ви уже відправили рецепт %s в одну аптеку. Погасити повторно? + Ви уже відправили декілька рецептів в одну аптеку. Погасити повторно? + Ви уже відправили декілька рецептів в одну аптеку. Погасити повторно? + Ви уже відправили декілька рецептів в одну аптеку. Погасити повторно? + + Введіть 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. + Детальніше + Зберегти дані доступу? + Зберегти + Не зберігати + Указівка + Введіть PIN-код своєї картки здоров\'я, щоб увійти на сервер рецептів.\n\n + На цьому пристрої не налаштовано біометричний захист. + Неможливо зберегти дані доступу. Заздалегідь налаштуйте біометричний захист (наприклад, відбиток пальця) на своєму пристрої. + Скасувати + Налаштування + Указівка + Указівка + Прийняти + Безпека ваших даних рецепта + \"Цей застосунок використовує найбезпечніший біометричний датчик, наданий вашим пристроєм, щоб зберігати ваші облікові дані в безпечній області пам’яті пристрою.\" + Біометрична безпека ваших даних доступу дозволяє вам відкривати цю програму в майбутньому, не вводячи свій PIN-код або картку здоров’я, а також переглядати, викликати, використовувати або видаляти рецепти. + Майте на увазі, що особи, з якими ви, можливо, спільно користуєтеся цим пристроєм і чиї біометричні функції можуть зберігатися на цьому пристрої, або які мають PIN-код пристрою, графічний ключ або пароль, також матимуть доступ до ваших рецептів. + На жаль, спроба невдала. + Аутентифікація за допомогою застосунку лікарняної каси була невдалою. + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index de74c1cb..7e9e5542 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,708 +1,506 @@ - 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. - Überprüfen Sie die Verbindung mit dem Internet und die Uhrzeit-/Datumseinstellung Ihres Geräts. - 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 - Lizenz Apothekensuche - 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 - Kennwortstärke nicht ausreichend - Kennwortstärke ausreichend - 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. - Biometrie - Kennwort - 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 - In Einlösung - 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. + Informationen zu Ihren Medikamenten + Mitteilungen Ihrer Apotheke + 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 akzeptiere die %s dieser App + Nutzungsbedingungen + Datenschutzerklärung + Bestätigen + Weiter + 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 + Nutzungsbedingungen akzeptieren + Datenschutzerklärung akzeptieren + Rezepte + Mitteilungen + 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 + Karte hinzufügen + Los geht’s + Was Sie benötigen: + Zugangsnummer eingeben + 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. + 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 + Sie haben sich erfolgreich angemeldet + Hinweis: es werden nur die Rezepte aus den letzten 100 Tagen heruntergeladen. + 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. + Apothekensuche + 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. + Deutschlands moderne Plattform für digitale Medizin + Mail schreiben + Webseite öffnen + Willkommen + Anmeldung starten + Entsperren + Anmelden + Abbrechen + Einstellungen + Name unbekannt + Gesundheitskarten + Karte hinzufügen + Sicherheit + Schützen Sie Ihre Gesundheitsinformationen vor dem Zugriff Unbefugter. + Rechtliches + Impressum + Datenschutz + Nutzungsbedingungen + 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. + Medikament %1$d + Als eingelöst markieren + Als nicht eingelöst markieren + Von diesem Gerät löschen + 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 + Mailadresse + Arbeitsunfall + Unfalltag + Unfallbetrieb- oder Arbeitgebernummer + Möchten Sie dieses Rezept unwiderruflich löschen? + Löschen + Abbrechen + 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 + Möchten Sie folgende Rezepte in der %s verbindlich einlösen? + Nur noch heute als Selbstzahlender einlösbar + 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. + 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 + 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? + Verordnung löschen + Mehr anzeigen + Weniger anzeigen + Technische Informationen + 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, ebenfalls Zugriff auf Ihre Rezepte erhalten. + Senden fehlgeschlagen + Ihr Warenkorb steht bereit + 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 + An Umfrage teilnehmen + +49 800 277 377 7 + app-feedback@gematik.de + Mehr erfahren + 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 + 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 + Ablehnen + Anonyme Analyse bleibt deaktiviert + %s Vielen Dank für Ihre Unterstützung! + \u2661 + Bestellen oder reservieren + Hinweis + Es kann zu einer Verzögerung kommen, bis eingelöste Rezepte in das Archiv verschoben werden. + Okay + Sie müssen angemeldet sein, um Rezepte zu löschen. + Fehler melden + Fehlerhafte Mitteilung erhalten + 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. + 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: + 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 + E-Rezept + Aktuell geöffnet + Aktuell geöffnet und in meiner Nähe + Filtern nach … + Botendienst + Versand + Filter + Häufige Filter + Filtern + Suche starten + Kein Standort verfügbar + Abbrechen + 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 + 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 die App absichern? + 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 + 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 + Mit Gesundheitskarte anmelden + In Kürze: Mit Krankenversicherungs-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. + Mann meldet sich mit Gesundheitskarte an + Frau meldet sich mit Krankenversicherungs-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 + Profile + Wie sollen wir Sie nennen? + Vorname und Nachname + 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 + 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 + 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 + In Einlösung + Eingelöst + Unbekannt + Details + Zugriffsprotokolle anzeigen + Wer hat wann auf Ihre Rezepte zugegriffen? + Zugangsschlüssel zum Rezeptdienst + Zugriffsprotokolle + Ausloggen + Einloggen + 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 + 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 + Abholcode erhalten + Lizenz Apothekensuche + Einlösen + Einlösen + Gescanntes Rezept + Gescannt am %s + Als eingelöst markiert am %s Wie möchten Sie fortfahren? Bestellen Demnächst verfügbar @@ -713,15 +511,12 @@ Weiter mit %s Rezept Weiter mit %s Rezepten - Authorization mit externem Anbieter wird durchgeführt - Authentisierung durch Krankenkassen-App ausstehend + Authentisierung durch Krankenversicherungs-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 Reservierung Meine Bestellung Jetzt reservieren - Jetzt reservieren (Demo-Modus) Jetzt bestellen Speichern Kontaktdaten und Adresse @@ -747,6 +542,52 @@ 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 + Wobei hilft Ihnen diese App? + Automatischer Rezeptempfang + Nutzung & Datenschutz + Schritt %s von %s + Bitte eingeben + 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 + Menge + Wirkstoff + Wirkstoffmenge + Wirkstoffstärke + Chargenbezeichnung + Verwendbar bis + Kategorie + Impfstoff + Zusammensetzung + Wirkstoffe: + 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 + Gerätesicherung wählen + Gerätesicherung gewählt + Profilnamen helfen Ihnen dabei, den Überblick zu behalten, wenn Sie die Rezepte für mehrere Personen verwalten möchten. + Warte auf Antwort Keine Rezepte Sie haben derzeit keine einlösbaren Rezepte. Aktualisieren @@ -762,19 +603,218 @@ 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. - Änderungen verwerfen? - Änderungen werden nicht automatisch gespeichert. - Verwerfen - Abbrechen - 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 - Anmeldung - Zugangsdaten speichern + Geräteverwaltung Verbundene Geräte - Ihr Gerät enstpricht nicht den Sicherheitsanforderungen für das dauerhafte Speichern von Gesundheitsdaten. - Mehr erfahren - "https://www.das-e-rezept-fuer-deutschland.de/fragen-antworten" + 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 + Gesendet am %s Uhr + Verordnetes Medikament: + + Erhaltenes Medikament + Erhaltene Medikamente + + + 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? + + Geben Sie für die Anmeldung am Rezepte-Server die PIN Ihrer Gesundheitskarte ein. + 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 + Karte entsperren + 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 + Geben Sie für die Anmeldung am Rezepte-Server die PIN Ihrer Gesundheitskarte ein.\n\n + 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 + 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 + Nach Name oder Adresse suchen + Apotheken + Zuletzt genutzt + 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 + 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 + 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 + %s Rezepte + Verlauf + Bestellung + Kostenfrei für den Anrufer. Servicezeiten: Mo - Fr 08:00 - 20:00 Uhr außer an bundeseinheitlichen Feiertagen + Apotheke + Select Environment + Save Environment + Wunsch-PIN wählen + Wunsch-PIN gespeichert + Speichern der Wunsch-PIN nicht möglich + 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 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 77% 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 8b1760fc..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,9 +16,8 @@ * */ -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 @@ -30,16 +29,16 @@ import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description -@ExperimentalCoroutinesApi +@OptIn(ExperimentalCoroutinesApi::class) class CoroutineTestRule( 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?) { 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 dadd2352..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.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,26 +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`() = runTest { - every { repo.fetchAttestationsLocal() } returns flowOf(listOfAttestationEntities()) + 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`() = runTest { - every { repo.fetchAttestationsLocal() } returns flow { emit(listOfAttestationEntities()) } + every { repo.fetchAttestationsLocal() } returns flowOf(attestation) coEvery { repo.fetchAttestationReportRemote(any()) } returns safetynetResult() coEvery { @@ -90,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`() { runTest { - every { repo.fetchAttestationsLocal() } returns flow { emit(listOfAttestationEntities()) } + 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`() = runTest { - every { repo.fetchAttestationsLocal() } returns flow { emit(listOfAttestationEntities()) } + 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 97f773dc..aa256833 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,15 +18,17 @@ 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 +import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -36,6 +38,7 @@ 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 +50,9 @@ 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,15 +60,26 @@ 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 + ) + ) + every { settingsUseCase.authenticationMode } returns flowOf(AuthenticationMode.Unspecified) + every { profilesUseCase.activeProfile } returns flowOf(mockk()) } @Test 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) + viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase, mockk()) assertEquals(true, viewModel.showInsecureDevicePrompt.first()) assertEquals(false, viewModel.showInsecureDevicePrompt.first()) @@ -81,10 +89,9 @@ class MainViewModelTest { 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) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase, mockk()) assertEquals(false, viewModel.showInsecureDevicePrompt.first()) assertEquals(false, viewModel.showInsecureDevicePrompt.first()) @@ -94,10 +101,9 @@ class MainViewModelTest { 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) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase, mockk()) assertEquals(true, viewModel.showDataTermsUpdate.first()) } @@ -106,10 +112,9 @@ class MainViewModelTest { 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) - viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase) + viewModel = MainViewModel(settingsUseCase, safetynetUseCase, profilesUseCase, mockk()) 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 b856901f..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/messages/ui/MessageViewModelTest.kt +++ /dev/null @@ -1,67 +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.flow -import kotlinx.coroutines.test.runTest -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`() = - runTest { - every { useCase.loadCommunicationsLocally(any()) } returns flow { listOfCommunicationsRead() } - viewModel.fetchCommunications().collect { - assertTrue(it.isNotEmpty()) - } - } - - @Test - fun `test loading communications - empty list`() = - runTest { - 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 406f980e..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.runTest -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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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 6df34a21..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 @@ -36,14 +36,14 @@ private val contacts = """ }, { "name":"Kasse 2", - "healthCardAndPinPhone":"", - "healthCardAndPinMail":"", - "healthCardAndPinUrl":"", - "pinUrl":"", - "subjectCardAndPinMail":"", - "bodyCardAndPinMail":"", - "subjectPinMail":"", - "bodyPinMail":"" + "healthCardAndPinPhone":null, + "healthCardAndPinMail":null, + "healthCardAndPinUrl":null, + "pinUrl":null, + "subjectCardAndPinMail":null, + "bodyCardAndPinMail":null, + "subjectPinMail":null, + "bodyPinMail":null } ] """.trimIndent() 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 b0a62926..18440c48 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,22 +18,20 @@ package de.gematik.ti.erp.app.pharmacy.ui -import androidx.lifecycle.SavedStateHandle +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.common.usecase.HintUseCase -import de.gematik.ti.erp.app.db.entities.ProfileColorNames -import de.gematik.ti.erp.app.pharmacy.repository.model.PharmacyContacts +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.OftenUsedPharmaciesUseCase 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.profiles.model.ProfilesData import de.gematik.ti.erp.app.profiles.usecase.ProfilesUseCase import de.gematik.ti.erp.app.profiles.usecase.model.ProfilesUseCaseData -import de.gematik.ti.erp.app.utils.CoroutineTestRule import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -49,35 +47,40 @@ class PharmacySearchViewModelTest { private lateinit var viewModel: PharmacySearchViewModel private lateinit var useCase: PharmacySearchUseCase + private lateinit var oftenUseCase: OftenUsedPharmaciesUseCase private lateinit var hintUseCase: HintUseCase private lateinit var profileUseCase: ProfilesUseCase - private lateinit var savedStateHandle: SavedStateHandle private val profile = ProfilesUseCaseData.Profile( - id = 0, + id = "", name = "", - insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation( - insurantName = null, - insuranceIdentifier = null, - insuranceName = null - ), + insuranceInformation = ProfilesUseCaseData.ProfileInsuranceInformation(), active = true, - color = ProfileColorNames.SPRING_GRAY, + color = ProfilesData.ProfileColorNames.SPRING_GRAY, + avatarFigure = ProfilesData.AvatarFigure.Initials, lastAuthenticated = null, - ssoToken = null, - accessToken = null + ssoTokenScope = null ) - private val tasks = listOf("A", "B", "C") + // private val tasks = listOf("A", "B", "C") private val prescriptions = listOf( PharmacyUseCaseData.PrescriptionOrder( - taskId = "A", accessCode = "1234", title = "Test", substitutionsAllowed = false + taskId = "A", + accessCode = "1234", + title = "Test", + substitutionsAllowed = false ), PharmacyUseCaseData.PrescriptionOrder( - taskId = "B", accessCode = "1234", title = "Test", substitutionsAllowed = false + taskId = "B", + accessCode = "1234", + title = "Test", + substitutionsAllowed = false ), PharmacyUseCaseData.PrescriptionOrder( - taskId = "C", accessCode = "1234", title = "Test", substitutionsAllowed = false + taskId = "C", + accessCode = "1234", + title = "Test", + substitutionsAllowed = false ) ) @@ -90,7 +93,6 @@ class PharmacySearchViewModelTest { provides = listOf(), openingHours = null, telematikId = "", - roleCode = listOf(), ready = false ) @@ -111,23 +113,18 @@ class PharmacySearchViewModelTest { useCase = mockk() hintUseCase = mockk() profileUseCase = mockk() - savedStateHandle = mockk(relaxed = true) - every { savedStateHandle.get(any()) } returns null - every { useCase.previousSearch } returns channelFlow { } // suspends + oftenUseCase = mockk() viewModel = PharmacySearchViewModel( - mockk(), - useCase, - profileUseCase, - hintUseCase, - coroutineRule.testDispatchProvider, - savedStateHandle, - mockk(relaxed = true) + useCase = useCase, + oftenUseCase = oftenUseCase, + profilesUseCase = profileUseCase, + dispatchers = coroutineRule.dispatchers ) coEvery { profileUseCase.profiles } returns flowOf(listOf(profile)) - coEvery { useCase.prescriptionDetailsForOrdering(tasks) } returns flowOf( + coEvery { useCase.prescriptionDetailsForOrdering("") } returns flowOf( PharmacyUseCaseData.OrderState( prescriptions = prescriptions, - contact = null + contact = contacts ) ) viewModel.onSelectPharmacy(pharmacy) @@ -136,10 +133,10 @@ class PharmacySearchViewModelTest { @Test fun `order screen state - default`() = runTest { - val state = viewModel.orderScreenState(tasks).first() + val state = viewModel.orderScreenState().first() assertEquals(profile, state.activeProfile) - assertEquals(null, state.contact) + assertEquals(contacts, state.contact) assertEquals(pharmacy, state.selectedPharmacy) assertEquals(orderOption, state.orderOption) assertEquals(prescriptions.map { Pair(it, true) }, state.prescriptions) @@ -153,10 +150,10 @@ class PharmacySearchViewModelTest { viewModel.onDeselectOrder(prescriptions[0]) - val state = viewModel.orderScreenState(tasks).first() + val state = viewModel.orderScreenState().first() assertEquals(profile, state.activeProfile) - assertEquals(null, state.contact) + assertEquals(contacts, state.contact) assertEquals(pharmacy, state.selectedPharmacy) assertEquals(orderOption, state.orderOption) assertEquals( @@ -168,7 +165,7 @@ class PharmacySearchViewModelTest { @Test fun `order screen state - set contacts`() = runTest { coEvery { useCase.saveShippingContact(any()) } answers {} - coEvery { useCase.prescriptionDetailsForOrdering(tasks) } returns flowOf( + coEvery { useCase.prescriptionDetailsForOrdering("") } returns flowOf( PharmacyUseCaseData.OrderState( prescriptions = prescriptions, contact = contacts @@ -180,7 +177,7 @@ class PharmacySearchViewModelTest { coroutineRule.testDispatcher.scheduler.runCurrent() coVerify(exactly = 1) { useCase.saveShippingContact(contacts) } - val state = viewModel.orderScreenState(tasks).first() + val state = viewModel.orderScreenState().first() assertEquals(profile, state.activeProfile) assertEquals(contacts, state.contact) 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 index d8c871cc..0042e2ef 100644 --- 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 @@ -18,23 +18,27 @@ package de.gematik.ti.erp.app.pharmacy.usecase.model -import com.squareup.moshi.Moshi +import de.gematik.ti.erp.app.CoroutineTestRule import de.gematik.ti.erp.app.pharmacy.usecase.PharmacySearchUseCase +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.profiles.usecase.ProfilesUseCase -import de.gematik.ti.erp.app.utils.CoroutineTestRule -import de.gematik.ti.erp.app.utils.testTasks +import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.impl.annotations.MockK import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import okhttp3.ResponseBody.Companion.toResponseBody +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 org.junit.Before import org.junit.Rule import org.junit.Test +import java.util.UUID import kotlin.test.assertTrue @ExperimentalCoroutinesApi @@ -44,58 +48,96 @@ class PharmacySearchUseCaseTest { val coroutineRule = CoroutineTestRule() private lateinit var useCase: PharmacySearchUseCase + + @MockK(relaxed = true) 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() + MockKAnnotations.init(this) + useCase = PharmacySearchUseCase( repository = mockk(), shippingContactRepository = mockk(), prescriptionRepository = repository, settingsUseCase = mockk(relaxed = true), - mapper = mockk(), - moshi = moshi, - dispatchProvider = coroutineRule.testDispatchProvider, + dispatchers = coroutineRule.dispatchers ) - 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`() = runTest { - val redeemOption = RemoteRedeemOption.Local - val telematicsId = "foo" - val result = useCase.redeemPrescription( - "Test", - redeemOption, - PharmacyUseCaseData.PrescriptionOrder( + fun `redeem prescription`() = runTest { + val communicationSlot = slot() + + coEvery { repository.redeemPrescription("1234567890", capture(communicationSlot)) } answers { + Result.success( + Unit + ) + } + + val orderId = UUID.randomUUID() + useCase.redeemPrescription( + profileId = "1234567890", + redeemOption = RemoteRedeemOption.Local, + orderId = orderId, + order = PharmacyUseCaseData.PrescriptionOrder( taskId = "", accessCode = "", title = "", substitutionsAllowed = false ), - PharmacyUseCaseData.ShippingContact( - name = "", + contact = PharmacyUseCaseData.ShippingContact( + name = "Test-Name", line1 = "", line2 = "", - postalCodeAndCity = "", + postalCodeAndCity = "123456", telephoneNumber = "", mail = "", deliveryInformation = "" ), - telematicsId + pharmacyTelematikId = "TID-1234567890" ) - assertTrue(result.isSuccess) + + val payload = """ + { + "version": "1", + "supplyOptionsType": "onPremise", + "name": "Test-Name", + "address": [ + "", + "", + "123456" + ], + "hint": "", + "phone": "" + } + """.trimIndent().replace("\\s".toRegex(), "") + + val expected = Communication().apply { + meta = Meta().addProfile(PROFILE) + identifier = listOf( + Identifier().apply { + system = "https://gematik.de/fhir/NamingSystem/OrderID" + value = orderId.toString() + } + ) + addBasedOn(Reference("Task/\$accept?ac=")) + addPayload( + Communication.CommunicationPayloadComponent().apply { + content = StringType(payload) + } + ) + status = Communication.CommunicationStatus.UNKNOWN + addRecipient( + Reference().setIdentifier( + Identifier().apply { + system = "https://gematik.de/fhir/NamingSystem/TelematikID" + value = "TID-1234567890" + } + ) + ) + } + + assertTrue { communicationSlot.captured.equalsDeep(expected) } } } 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 7cd7b11a..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.runTest -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`() = runTest { - 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 f77bb9e9..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/MapperTest.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.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.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()) - ) - assertEquals(task.taskId, uiPrescriptionOrder.taskId) - assertEquals(task.accessCode, uiPrescriptionOrder.accessCode) - assertEquals(false, uiPrescriptionOrder.substitutionsAllowed) - } -} diff --git a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetailTest.kt b/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetailTest.kt deleted file mode 100644 index 9a159406..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/detail/ui/model/UIPrescriptionDetailTest.kt +++ /dev/null @@ -1,43 +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.utils.detailPrescriptionScanned -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.time.OffsetDateTime - -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")) - } -} 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 fadcd234..00000000 --- a/android/src/test/java/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepositoryTest.kt +++ /dev/null @@ -1,170 +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.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.runTest -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.failure(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`() = - runTest { - 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 { } - - runTest { - 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.failure(IllegalArgumentException("")) - } - - runTest { - 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 index 4116dbd6..ebca4fa5 100644 --- 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 @@ -74,7 +74,7 @@ class RemoteDataSourceTest { assertEquals(expected, actual.getOrThrow().entry[0].resource.resourceType.name) } - private fun getUnsafeOkHttpClient(): OkHttpClient? { + private fun getUnsafeOkHttpClient(): OkHttpClient { return try { // Create a trust manager that does not validate certificate chains val trustAllCerts: Array = arrayOf( 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 e34c9f10..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.runTest -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`() = - runTest { - 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`() = - runTest { - 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 178d6b88..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.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toCollection -import kotlinx.coroutines.test.runTest -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 - -@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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = - runTest { - 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`() = runTest { - 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 d6f096c3..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 @@ -35,46 +35,60 @@ 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(any()) } answers { callOriginal() } - every { useCase.scannedRecipes() } answers { callOriginal() } - every { useCase.redeemedPrescriptions(any()) } 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`() = runTest { - assertEquals(testSyncedTasksOrdered.map { it.taskId }, useCase.syncedRecipes(LocalDate.parse("2021-02-01")).first().map { it.taskId }) + 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`() = runTest { - assertEquals(testScannedTasksOrdered.map { it.taskId }, useCase.scannedRecipes().first().map { it.taskId }) + assertEquals( + testScannedTasksOrdered.map { it.taskId }, + useCase.scannedActiveRecipes("").first().map { it.taskId } + ) } @Test fun `redeemed recipes - should return redeemed tasks ordered by redeemedOn`() = runTest { - assertEquals(testRedeemedTasksOrdered.map { it.taskId }, useCase.redeemedPrescriptions(LocalDate.parse("2021-02-01")).first().map { it.taskId }) + 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 8ca4ff84..65283e81 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,96 +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.ProfileColorNames -import de.gematik.ti.erp.app.db.entities.ProfileEntity +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.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.Initials + ) + @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`() = runTest { - profilesUseCase.profiles.first().let { - assertEquals(expectedProfiles.size, it.size) - } - } + fun `update profile name - should sanitize new name`() = runTest { + profilesUseCase.updateProfileName(profile, " T es t ") - @Test - fun `active profile name - should return tester 2`() = runTest { - 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`() = - runTest { - profilesUseCase.activeProfile().first().let { - assertEquals(expectedActiveProfile, it) - } + fun `update profile with empty name - should not update name`() = runTest { + assertFails { + profilesUseCase.updateProfileName(profile, "") } + coVerify(exactly = 0) { profilesRepository.updateProfileName(any(), any()) } + } + @Test - fun `get profile by id (2) - should return expected profile (2)`() = - runTest { - 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..81171b22 --- /dev/null +++ b/android/src/test/java/de/gematik/ti/erp/app/settings/usecase/SettingsUseCaseTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 + ) + ) + + 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 + ) + ) + + initSettings() + + assertEquals(false, settings.showDataTermsUpdate.first()) + } +} diff --git a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardKeyReference.kt b/android/src/test/java/de/gematik/ti/erp/app/utils/Proxy.kt similarity index 65% rename from android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardKeyReference.kt rename to android/src/test/java/de/gematik/ti/erp/app/utils/Proxy.kt index 22282f2a..24adf3b8 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/cardwall/model/nfc/card/ICardKeyReference.kt +++ b/android/src/test/java/de/gematik/ti/erp/app/utils/Proxy.kt @@ -16,19 +16,17 @@ * */ -package de.gematik.ti.erp.app.cardwall.model.nfc.card +package de.gematik.ti.erp.app.utils -/** - * interface that identifier: - * - * - symmetric authentication object, - * - symmetric map connection object, - * - or private key object - */ -interface ICardKeyReference { - fun calculateKeyReference(dfSpecific: Boolean): Int +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.URI - companion object { - const val DF_SPECIFIC_PWD_MARKER = 0x80 +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 65ad14c0..bb621f02 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 @@ -19,24 +19,16 @@ 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.db.entities.TaskStatus -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.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.model.ScannedTaskData +import de.gematik.ti.erp.app.prescription.model.SyncedTaskData import de.gematik.ti.erp.app.prescription.usecase.createMatrixCode import de.gematik.ti.erp.app.redeem.ui.BitMatrixCode import org.hl7.fhir.r4.model.Bundle -import org.hl7.fhir.r4.model.MedicationDispense import java.io.File import java.io.IOException -import java.time.LocalDate -import java.time.OffsetDateTime +import java.time.Duration +import java.time.Instant +import java.util.UUID const val ASSET_BASE_PATH = "src/test/assets/" @@ -44,403 +36,230 @@ 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"), +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, + accessCode = accessCode, + lastModified = lastModified, + organization = SyncedTaskData.Organization( + name = organizationName, + address = null, + uniqueIdentifier = null, + phone = null, + mail = null ), - 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"), + practitioner = SyncedTaskData.Practitioner( + name = practitionerName, + qualification = null, + practitionerIdentifier = null ), - Task( - taskId = "Task/1aeea131-651a-4229-8b16-9bdc73dbdb6e", - accessCode = "3ea8dc08e5aa4693825437cf73e6d0333ea8dc08e5aa4693825437cf73e6d033", - organization = "Praxis Glücklicher gehts nicht", - medicationText = "Gummibärchen", - expiresOn = LocalDate.parse("2021-04-01"), - authoredOn = OffsetDateTime.parse("2020-12-05T09:49:46+00:00"), - profileName = "Tester", - status = TaskStatus.Ready + patient = SyncedTaskData.Patient( + name = null, + address = null, + birthdate = null, + insuranceIdentifier = null ), - 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", - status = TaskStatus.Ready + insuranceInformation = SyncedTaskData.InsuranceInformation( + name = null, + status = null ), - 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", - status = TaskStatus.Ready + 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 = "" + ) + ) + + ), + dateOfAccident = null, + location = null, + emergencyFee = null, + substitutionAllowed = false, + dosageInstruction = null ), + medicationDispenses = if (medicationDispenseWhenHandedOver != null) { + listOf( + SyncedTaskData.MedicationDispense( + dispenseId = null, + patientIdentifier = "", + medication = null, + wasSubstituted = false, + dosageInstruction = "", + performer = "", + whenHandedOver = medicationDispenseWhenHandedOver + ) + ) + } else { + emptyList() + }, + communications = listOf() ) -} -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"), + // 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" ), - 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", + // 1 + syncedTask( + lastModified = Instant.parse("2020-12-05T14: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 = "Bonbons" ), - 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", + // 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" ), - 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", + // 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", + // 0 + scannedTask( + scannedOn = Instant.parse("2020-12-02T14:48:36Z"), + redeemedOn = Instant.parse("2020-12-05T14:49:47Z"), + sentOn = null ), - 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", + // 1 + scannedTask( + scannedOn = Instant.parse("2020-12-02T14:48:37Z"), + redeemedOn = null, + sentOn = null ), - 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", + // 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"), - ), + testSyncedTasks[3], + testSyncedTasks[4], + testSyncedTasks[2] ) -} -fun detailPrescriptionScanned(scannedOn: OffsetDateTime = OffsetDateTime.now()) = - UIPrescriptionDetailScanned( - taskId = "4711", - redeemedOn = OffsetDateTime.now(), - "accessCode", - testMatrix(), - 1, - scannedOn, - unRedeemMorePossible = true +// keep in sync with `testScannedTasks` +val testScannedTasksOrdered by lazy { + listOf( + testScannedTasks[3], + testScannedTasks[2], + testScannedTasks[1] ) - -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") -} +// keep in sync with `testSyncedTasks` +val testRedeemedTasksOrdered = + listOf( + testSyncedTasks[0], + testScannedTasks[0], + testSyncedTasks[1] + ) -fun allAuditEvents(): Bundle { - return getBundleFromAssetFileName("audit_event_dev.json") -} +val testRedeemedTaskIdsOrdered + get() = + testRedeemedTasksOrdered.map { + when (it) { + is ScannedTaskData.ScannedTask -> it.taskId + is SyncedTaskData.SyncedTask -> it.taskId + else -> error("wrong type") + } + } -fun emptyAuditEvents(): Bundle { - return getBundleFromAssetFileName("empty_audit_event_dev.json") -} +fun testMatrix() = BitMatrixCode(createMatrixCode("Task/$4711/\$accept?ac=accessCode")) fun testPharmacySearchBundle(): Bundle { val parser = fhirContext.newJsonParser() @@ -448,83 +267,7 @@ fun testPharmacySearchBundle(): Bundle { 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 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 - ) - @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" - ) -) - -val validScannedCode2 = ValidScannedCode( - scannedCode, - mutableListOf( - "Task/234fabe0964598efd23f34dd23e122b2323344ea8e8934dae23e2a9a934513bc/\$accept?ac=777bea0e13cc9c42ceec14aec3ddee2263325dc2c6c699db115f58fe423607ea", - ) -) 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 ae693f81..7fd8ff61 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 @@ -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 { } @@ -184,7 +186,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 +202,7 @@ class TruststoreTest { assertTrue( try { TrustedTruststore.create( - TestCertificates.OCSPList.OCSPList, + TestCertificates.OCSP.OCSPList, TestCertificates.Vau.CertList, TestCertificates.RCA3.X509Certificate, Duration.ofHours(12), @@ -222,12 +224,12 @@ class TruststoreTest { 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), @@ -253,13 +255,13 @@ class TruststoreTest { } 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 +269,7 @@ class TruststoreTest { ) } answers { error("invalid ocsp") - } andThen { + } andThenAnswer { trustedTruststore } @@ -295,13 +297,13 @@ class TruststoreTest { } 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), @@ -351,13 +353,13 @@ class TruststoreTest { } 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), @@ -391,13 +393,13 @@ class TruststoreTest { } 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), @@ -433,13 +435,13 @@ class TruststoreTest { } 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), @@ -470,13 +472,13 @@ class TruststoreTest { } 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), @@ -509,13 +511,13 @@ class TruststoreTest { } 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 2e3c0e14..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,7 +18,7 @@ package de.gematik.ti.erp.app.vau.repository -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 @@ -50,7 +50,7 @@ class VauRepositoryTest { fun setup() { MockKAnnotations.init(this) - repo = VauRepository(localDataSource, remoteDataSource, coroutineRule.testDispatchProvider) + repo = VauRepository(localDataSource, remoteDataSource, coroutineRule.dispatchers) } @Test @@ -59,11 +59,11 @@ class VauRepositoryTest { 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.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() } @@ -78,7 +78,7 @@ class VauRepositoryTest { coEvery { localDataSource.saveLists(any(), any()) } coAnswers { } coEvery { localDataSource.deleteAll() } coAnswers { } coEvery { remoteDataSource.loadCertificates() } coAnswers { Result.failure(IOException()) } - coEvery { remoteDataSource.loadOcspResponses() } coAnswers { Result.success(TestCertificates.OCSPList.OCSPList) } + coEvery { remoteDataSource.loadOcspResponses() } coAnswers { Result.success(TestCertificates.OCSP.OCSPList) } val r = try { repo.withUntrusted { certs, ocsp -> @@ -101,7 +101,7 @@ class VauRepositoryTest { coEvery { localDataSource.loadUntrusted() } coAnswers { Pair( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } coEvery { localDataSource.saveLists(any(), any()) } coAnswers { } @@ -109,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() } @@ -124,7 +124,7 @@ class VauRepositoryTest { coEvery { localDataSource.loadUntrusted() } coAnswers { Pair( TestCertificates.Vau.CertList, - TestCertificates.OCSPList.OCSPList + TestCertificates.OCSP.OCSPList ) } @@ -134,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..e1b53609 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("io.realm.kotlin") version "1.0.1" apply false + id("org.jetbrains.kotlin.android") version "1.7.0" apply false + id("com.android.application") version "7.2.0" apply false + id("com.android.library") version "7.2.0" 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("com.google.devtools.ksp") version "1.7.0-1.0.6" 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." @@ -78,3 +88,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/ci/junit-report.py b/ci/junit-report.py new file mode 100644 index 00000000..50cbc4a6 --- /dev/null +++ b/ci/junit-report.py @@ -0,0 +1,32 @@ +from junitparser import JUnitXml +from junitparser import TestSuite, TestCase +import argparse + + +def parse(in_file): + suite: TestSuite = JUnitXml.fromfile(in_file) + total = suite.tests - suite.skipped + error = suite.errors + suite.failures + success_rate = (total - error) / total * 100.0 + emoji = '😀' if success_rate > 95 else '🫣' if success_rate > 50 else '😟' if success_rate > 25 else '😔' + print(f"Espresso Test Run {emoji} Success {int(success_rate)}% - Total {total}") + print() + + for case in suite: + case: TestCase = case + print(f"Test: {'✅' if case.is_passed else '❌'} {case.name}") + + +# init + +parser = argparse.ArgumentParser(description='Parses junit xml report files and reports stats') + +parser.add_argument('report', + type=argparse.FileType('r', encoding='UTF-8'), + help='input file') + +args = parser.parse_args() + +parse( + in_file=args.report +) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 90ac6b57..dd99f5b8 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -1,7 +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.kotlinX +import de.gematik.ti.erp.app import de.gematik.ti.erp.overriding import org.jetbrains.compose.compose import org.jetbrains.kotlin.util.capitalizeDecapitalize.capitalizeAsciiOnly @@ -10,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") @@ -43,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() @@ -66,14 +68,13 @@ 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 PIWIK_TRACKER_ID_GOOGLE: String by overriding() -val PIWIK_TRACKER_ID_HUAWEI: 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") { @@ -84,24 +85,93 @@ kotlin { sourceSets { val commonMain by getting { dependencies { - api(compose.runtime) - api(compose.foundation) - api(compose.material) - api(compose.materialIconsExtended) - api(compose.ui) - - kotlinX { - api(coroutines("core")) + 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(fhir) + 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 { @@ -109,15 +179,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 { @@ -136,7 +217,7 @@ enum class Platforms { } enum class Environments { - PU, TU, RU, TR + PU, TU, RU, DEVRU, TR } enum class Types { @@ -150,11 +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( @@ -163,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") } } @@ -203,57 +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.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 ) } } @@ -267,14 +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, "TEST_RUN_WITH_IDP_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/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/idp/usecase/IdpCryptoProvider.kt b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt similarity index 82% rename from android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt rename to common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt index 1376d526..844b263d 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt +++ b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt @@ -20,11 +20,10 @@ package de.gematik.ti.erp.app.idp.usecase import java.security.KeyStore import java.security.Signature -import javax.inject.Inject -class IdpCryptoProvider @Inject constructor() { - fun keyStoreInstance(): KeyStore = +actual class IdpCryptoProvider { + actual fun keyStoreInstance(): KeyStore = KeyStore.getInstance("AndroidKeyStore") - fun signatureInstance(algorithm: String = "SHA256withECDSA"): Signature = + actual fun signatureInstance(algorithm: String): Signature = Signature.getInstance(algorithm, "AndroidKeyStoreBCWorkaround") } diff --git a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt similarity index 61% rename from android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt rename to common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt index 16fa736d..1001b802 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt +++ b/common/src/androidMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpDeviceInfoProvider.kt @@ -18,13 +18,11 @@ package de.gematik.ti.erp.app.idp.usecase -import javax.inject.Inject - -class IdpDeviceInfoProvider @Inject constructor() { - val deviceName: String = "Some Android" - val manufacturer: String = android.os.Build.MANUFACTURER - val productName: String = android.os.Build.PRODUCT - val model: String = android.os.Build.MODEL - val operatingSystem: String = "Android" - val operatingSystemVersion: String = android.os.Build.VERSION.SDK_INT.toString() +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 74% 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..66f0cca1 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,14 @@ package de.gematik.ti.erp.app.api +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier import okhttp3.ResponseBody import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Communication 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,19 +35,19 @@ interface ErpService { @GET("Task/{id}") suspend fun taskWithKBVBundle( - @Tag profileName: String, + @Tag profileId: ProfileIdentifier, @Path("id") id: String ): 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 @@ -54,8 +56,8 @@ interface ErpService { * @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, @@ -64,16 +66,17 @@ interface ErpService { @POST("Communication") suspend fun communication( - @Tag profileName: String, - @Body communication: Communication + @Tag profileId: ProfileIdentifier, + @Body communication: Communication, + @Header("X-AccessCode") accessCode: String? = null ): Response @GET("Communication") - suspend fun communication(@Tag profileName: String): Response + suspend fun communication(@Tag profileId: ProfileIdentifier): Response - @GET("MedicationDispense/{id}") - suspend fun medicationDispense( + @GET("MedicationDispense") + suspend fun bundleOfMedicationDispenses( @Tag profileName: String, - @Path("id") id: String + @Query("identifier") id: String ): Response } diff --git a/android/src/main/java/de/gematik/ti/erp/app/api/FhirConverterFactory.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/FhirConverterFactory.kt similarity index 100% rename from android/src/main/java/de/gematik/ti/erp/app/api/FhirConverterFactory.kt rename to common/src/commonMain/kotlin/de/gematik/ti/erp/app/api/FhirConverterFactory.kt 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 71% 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 efc67c34..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 @@ -40,7 +42,10 @@ suspend fun safeApiCall( 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.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.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/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/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 84% 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..ea89defa 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,27 @@ 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.LONG_PASSWORD, + 0x6985 to ResponseStatus.SHORT_PASSWORD, + 0x6A88 to ResponseStatus.PASSWORD_NOT_FOUND ) /** @@ -210,5 +230,8 @@ enum class ResponseStatus { DUPLICATED_OBJECTS, DF_NAME_EXISTS, OFFSET_TOO_BIG, - INSTRUCTION_NOT_SUPPORTED; + INSTRUCTION_NOT_SUPPORTED, + PUK_BLOCKED, + LONG_PASSWORD, + SHORT_PASSWORD; } 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..6afe2241 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/command/UnlockEgkCommand.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.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 + +/** + * 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( + changeSecret: Boolean, + passwordReference: PasswordReference, + dfSpecific: Boolean, + puk: EncryptedPinFormat2, + newSecret: EncryptedPinFormat2? +) = + HealthCardCommand( + expectedStatus = unlockEgkStatus, + cla = CLA, + ins = UNLOCK_EGK_INS, + p1 = if (changeSecret) { + MODE_VERIFICATION_DATA_NEW_SECRET + } else { + MODE_VERIFICATION_DATA + }, + p2 = passwordReference.calculateKeyReference(dfSpecific), + data = if (changeSecret) { + 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..0f08b180 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/card.model/exchange/PinExchange.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.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.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(changeSecret: Boolean, puk: String, newSecret: String): ResponseStatus { + HealthCardCommand.select(selectParentElseRoot = false, readFirst = false) + .executeSuccessfulOn(this) + + val passwordReference = PasswordReference(Mf.MrPinHome.PWID) + + val response = + HealthCardCommand.unlockEgk( + changeSecret = changeSecret, + passwordReference = passwordReference, + dfSpecific = false, + puk = EncryptedPinFormat2(puk), + newSecret = if (changeSecret) { + EncryptedPinFormat2(newSecret) + } else { null } + ).executeSuccessfulOn(this) + + println("Response: $response") + + 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..976890b1 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/Migrations.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.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.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.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 = 4L + +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 + ), + migrateOrInitialize = { migrationStartedFrom -> + queryFirst() ?: run { + copyToRealm( + SettingsEntityV1() + ) + } + if (migrationStartedFrom < 2L) { + query().find().forEach { + it._avatarFigure = AvatarFigureV1.Initials.toString() + } + } + 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 + } + } + } + } + ) +) 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..38f0e9e1 --- /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 + +internal fun RealmInstant.toLocalDateTime(offset: ZoneOffset = ZoneOffset.UTC): LocalDateTime = + LocalDateTime.ofEpochSecond(epochSeconds, nanosecondsOfSecond, offset) + +internal 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..60efd697 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/Delegates.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.db.entities + +import java.lang.IllegalArgumentException +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KMutableProperty +import kotlin.reflect.KProperty +import org.bouncycastle.util.encoders.Base64 + +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) }) + } + } 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..b39daf64 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Profile.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.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, + Initials, + 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.Initials.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..cdae9b05 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/Settings.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.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.entities.v1.task.OftenUsedPharmacyEntityV1 +import de.gematik.ti.erp.app.db.toRealmInstant +import io.realm.kotlin.Deleteable +import io.realm.kotlin.ext.realmListOf +import io.realm.kotlin.types.RealmInstant +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.RealmList +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 pharmacySearch: PharmacySearchEntityV1? = PharmacySearchEntityV1() + var oftenUsedPharmacies: RealmList = realmListOf() + + 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) } + yield(oftenUsedPharmacies) + } +} diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/ShippingContact.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/ShippingContact.kt new file mode 100644 index 00000000..5c0b9112 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/ShippingContact.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 + +import de.gematik.ti.erp.app.db.entities.Cascading +import io.realm.kotlin.Deleteable +import io.realm.kotlin.types.RealmObject + +class ShippingContactEntityV1 : RealmObject, Cascading { + var address: AddressEntityV1? = AddressEntityV1() + var name: String = "" + var telephoneNumber: String = "" + var mail: String = "" + var deliveryInformation: String = "" + + 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/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/Ingredient.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/Ingredient.kt index 5aadb8d3..618e9abb 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/Ingredient.kt @@ -16,19 +16,20 @@ * */ -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 IngredientEntityV1 : RealmObject, Cascading { + var text: String = "" + var form: String? = null + var amount: String? = null + var strength: RatioEntityV1? = 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 { + 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..fc2ab442 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Medication.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 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 + +// 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: RealmInstant? = null + 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..9afa4193 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/MedicationRequest.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.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 + +class MedicationRequestEntityV1 : RealmObject, Cascading { + var medication: MedicationEntityV1? = null + var dateOfAccident: RealmInstant? = null // unfalltag + var location: String? = null // unfallbetrieb + var emergencyFee: Boolean? = null // emergency service fee = notfallgebuehr + var substitutionAllowed: Boolean = false + var dosageInstruction: String? = null + + override fun objectsToFollow(): Iterator = iterator { + medication?.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/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Patient.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Patient.kt new file mode 100644 index 00000000..f2a553b4 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Patient.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.db.entities.v1.task + +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 + +class PatientEntityV1 : RealmObject, Cascading { + var name: String? = null + var address: AddressEntityV1? = null + var birthdate: RealmInstant? = null + var insuranceIdentifier: String? = null + + 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/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ratio.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ratio.kt new file mode 100644 index 00000000..09763bce --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/Ratio.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.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 RatioEntityV1 : RealmObject, Cascading { + var numerator: QuantityEntityV1? = null + var denominator: QuantityEntityV1? = null + + 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..261b7d06 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/db/entities/v1/task/SyncedTask.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.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 + + 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/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/PharmacyMapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.kt new file mode 100644 index 00000000..8acc49e7 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapper.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.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.stringValue +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.JsonElement +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"), stringValue("https://gematik.de/fhir/NamingSystem/TelematikID")) + .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 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 = 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/parser/Comperator.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Comperator.kt new file mode 100644 index 00000000..e6bfcdc5 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Comperator.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.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) 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..ac284a06 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/fhir/parser/Converter.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT 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.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.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 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) + } + +/** + * 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.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.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 77% 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 dbf6fcde..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 @@ -28,6 +28,7 @@ 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 @@ -37,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 @@ -67,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 @@ -83,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", @@ -94,7 +95,7 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun authorization( @Url url: String, @@ -104,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 @@ -118,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 /** @@ -136,12 +137,12 @@ interface IdpService { @FormUrlEncoded @POST @Headers( - "Accept: application/json", + "Accept: application/json" ) suspend fun postPairing( @Url url: String, @Header("Authorization") bearerToken: String, - @Field("encrypted_registration_data") data: String, + @Field("encrypted_registration_data") data: String ): Response /** @@ -149,24 +150,36 @@ interface IdpService { */ @GET @Headers( - "Accept: application/json", + "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 /** @@ -176,9 +189,9 @@ 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 { 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 77% 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 26c69627..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,21 +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.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 ) { @@ -58,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 ) } @@ -73,15 +74,17 @@ 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 ) } @@ -95,6 +98,23 @@ class IdpRemoteDataSource @Inject constructor( 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 */ @@ -115,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 ) } @@ -136,7 +156,9 @@ 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) { @@ -158,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 76% 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 bff79889..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,18 +18,24 @@ package de.gematik.ti.erp.app.idp.usecase -import com.squareup.moshi.Moshi 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.PairingResponseEntries 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 @@ -40,21 +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 deviceInfo: IdpDeviceInfoProvider ) { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } suspend fun registerDeviceWithHealthCard( initialData: IdpInitialData, @@ -64,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(deviceInfo.deviceName) - Timber.d("Device information: $deviceInformation") + Napier.d("Device information: $deviceInformation") val healthCardCertificateHolder = X509CertificateHolder(healthCardCertificate) @@ -89,7 +97,9 @@ 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 repository.postPairing( @@ -102,17 +112,52 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( suspend fun getPairedDevices( initialData: IdpInitialData, accessToken: String - ): PairingResponseEntries { + ): List> { val (config, pukSigKey, pukEncKey) = initialData val encryptedAccessToken = buildEncryptedAccessToken( - accessToken, idpPukSigKey = pukSigKey.jws.publicKey, idpPukEncKey = pukEncKey.jws.publicKey, + accessToken, + idpPukSigKey = pukSigKey.jws.publicKey, + idpPukEncKey = pukEncKey.jws.publicKey ) - return repository.getPairing( + + 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, @@ -123,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 { @@ -144,7 +189,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( aliasOfSecureElementEntry: ByteArray, privateKeyOfSecureElementEntry: PrivateKey, - signatureObjectOfSecureElementEntry: Signature, + signatureObjectOfSecureElementEntry: Signature ): IdpAuthFlowResult { val (config, pukSigKey, pukEncKey, state, nonce) = initialData val codeVerifier = initialData.codeVerifier @@ -161,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, @@ -180,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 ) @@ -191,18 +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, + state: IdpState ): URI { - val redirect = URI(repository.postBiometricAuthenticationData(url, codeChallenge.compactSerialization).getOrThrow()) + val redirect = + URI(repository.postBiometricAuthenticationData(url, codeChallenge.compactSerialization).getOrThrow()) val redirectState = IdpService.extractQueryParameter(redirect, "state") require(state.state == redirectState) { "Invalid state" } @@ -229,7 +282,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( fun buildPairingData( keyAliasOfSecureElement: ByteArray, subjectPublicKeyInfoOfSecureElement: SubjectPublicKeyInfo, - healthCardCertificate: X509CertificateHolder, + healthCardCertificate: X509CertificateHolder ): PairingData { require(keyAliasOfSecureElement.size == 32) @@ -259,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 ) @@ -285,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) { @@ -309,7 +362,7 @@ class IdpAlternateAuthenticationUseCase @Inject constructor( ), keyAliasOfSecureElement = Base64Url.encode(keyAliasOfSecureElement), deviceInformation = deviceInformation, - authenticationMethod = authenticationMethod.methods, + authenticationMethod = authenticationMethod.methods ) } @@ -322,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 82% 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 170fb43a..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 @@ -19,17 +19,25 @@ package de.gematik.ti.erp.app.idp.usecase import de.gematik.ti.erp.app.BCProvider -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 @@ -41,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 @@ -56,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] @@ -174,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 ) { @@ -198,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 { @@ -206,7 +139,7 @@ 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 } @@ -243,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 @@ -255,7 +189,8 @@ class IdpBasicUseCase @Inject constructor( state = state, nonce = nonce, scope = scope, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) return IdpChallengeFlowResult( @@ -266,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 @@ -305,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 ) @@ -316,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 @@ -339,7 +279,8 @@ class IdpBasicUseCase @Inject constructor( state = state, nonce = nonce, scope = scope, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) val redirect = postUnsignedChallengeWithSsoTokenAndGetRedirect( @@ -358,7 +299,8 @@ class IdpBasicUseCase @Inject constructor( codeVerifier = codeVerifier, code = codeFromRedirect, pukEncKey = pukEncKey, - pukSigKey = pukSigKey + pukSigKey = pukSigKey, + redirectUri = redirectUri ) return IdpRefreshFlowResult(scope, idpTokenResult.decryptedAccessToken) @@ -369,7 +311,7 @@ class IdpBasicUseCase @Inject constructor( suspend fun postSignedChallengeAndGetRedirect( url: String, codeChallenge: JsonWebEncryption, - state: IdpState, + state: IdpState ): URI { val redirect = URI(repository.postSignedChallenge(url, codeChallenge.compactSerialization).getOrThrow()) @@ -383,7 +325,7 @@ class IdpBasicUseCase @Inject constructor( url: String, unsignedCodeChallenge: String, ssoToken: String, - state: IdpState, + state: IdpState ): URI { val redirect = URI(repository.postUnsignedChallengeWithSso(url, ssoToken, unsignedCodeChallenge).getOrThrow()) @@ -399,14 +341,16 @@ class IdpBasicUseCase @Inject constructor( state: IdpState, nonce: IdpNonce, scope: IdpScope, - pukSigKey: JWSPublicKey + pukSigKey: JWSPublicKey, + redirectUri: String ): IdpUnsignedChallenge { val signedChallenge = repository.fetchChallenge( url = url, codeChallenge = codeChallenge, state = state.state, nonce = nonce.nonce, - isDeviceRegistration = scope == IdpScope.BiometricPairing + isDeviceRegistration = scope == IdpScope.BiometricPairing, + redirectUri = redirectUri ).map { it.challenge.jws.apply { key = pukSigKey.jws.publicKey @@ -416,11 +360,11 @@ class IdpBasicUseCase @Inject constructor( // 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, @@ -436,7 +380,7 @@ class IdpBasicUseCase @Inject constructor( code: String, pukEncKey: JWSPublicKey, pukSigKey: JWSPublicKey, - redirectUri: String = REDIRECT_URI + redirectUri: String ): IdpTokenResult { val symmetricalKey = generateRandomAES256Key() @@ -462,7 +406,7 @@ class IdpBasicUseCase @Inject constructor( val json = decryptAccessToken(it, symmetricalKey) IdpTokenResult( - decryptedAccessToken = JSONObject(json)["njwt"] as String, + decryptedAccessToken = Json.parseToJsonElement(json).jsonObject["njwt"]!!.jsonPrimitive.content, idTokenPayload = idTokenPayload ) }.getOrThrow() @@ -516,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 { @@ -541,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 } /** @@ -549,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 ) } @@ -569,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..7ae15bc4 --- /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 + } + } + + /** + * 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/prescription/repository/Mapper.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/repository/Mapper.kt new file mode 100644 index 00000000..bef0480f --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/prescription/repository/Mapper.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.prescription.repository + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.DomainResource +import org.hl7.fhir.r4.model.MedicationRequest +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Task +import java.time.Instant + +typealias FhirTask = org.hl7.fhir.r4.model.Task +typealias FhirCommunication = org.hl7.fhir.r4.model.Communication +typealias FhirPractitioner = org.hl7.fhir.r4.model.Practitioner +typealias FhirMedication = org.hl7.fhir.r4.model.Medication +typealias FhirRatio = org.hl7.fhir.r4.model.Ratio +typealias FhirQuantity = org.hl7.fhir.r4.model.Quantity +typealias FhirMedicationRequest = MedicationRequest +typealias FhirMedicationDispense = org.hl7.fhir.r4.model.MedicationDispense +typealias FhirPatient = org.hl7.fhir.r4.model.Patient +typealias FhirAddress = org.hl7.fhir.r4.model.Address +typealias FhirElement = org.hl7.fhir.r4.model.Element +typealias FhirTaskStatus = org.hl7.fhir.r4.model.Task.TaskStatus +typealias FhirResource = org.hl7.fhir.r4.model.Resource +typealias FhirOrganization = org.hl7.fhir.r4.model.Organization +typealias FhirCoverage = org.hl7.fhir.r4.model.Coverage + +inline fun Bundle.extractResources(): List = + entry.map { it.resource }.filterIsInstance() + +@Suppress("UNCHECKED_CAST") +fun Bundle.BundleEntryComponent.entries(): List { + return resource.getChildByName("entry").values as List +} + +fun FhirTask.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 +} + +// extracts the very first dosage instruction + +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 FhirTask.accessCode(): String? { + identifier.forEach { + if (it.hasSystem()) { + if (it.system == "https://gematik.de/fhir/NamingSystem/AccessCode") { + return it.value + } + } + } + return null +} + +fun FhirTask.extractDateExtension(extensionUrl: String): Instant? { + val fhirDate = this.getExtensionByUrl(extensionUrl)?.value as DateType? + + return fhirDate?.value?.toInstant() +} 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..7d1d8d2d --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/model/ProfilesData.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.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, + Initials, + 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..f2f833ee --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/profiles/repository/ProfilesRepository.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.Initials -> ProfilesData.AvatarFigure.Initials + 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 + } + ) + } + } + + // 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.Initials -> AvatarFigureV1.Initials + 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.Initials + } + } + } +} 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..f61a5884 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/AuditEventsRepository.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.protocol.repository + +import de.gematik.ti.erp.app.DispatchProvider +import de.gematik.ti.erp.app.prescription.repository.extractResources +import de.gematik.ti.erp.app.profiles.repository.ProfileIdentifier +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.AuditEvent + +private const val AUDIT_EVENT_PAGE_SIZE = 50 + +class AuditEventsRepository( + private val remoteDataSource: AuditEventRemoteDataSource, + private val localDataSource: AuditEventLocalDataSource, + private val dispatchers: DispatchProvider +) { + suspend fun downloadAuditEvents(profileId: ProfileIdentifier): Result = withContext(dispatchers.IO) { + while (true) { + val result = downloadAuditEvents( + profileId = profileId, + count = AUDIT_EVENT_PAGE_SIZE + ) + if (result.isFailure || (result.getOrNull()!! != AUDIT_EVENT_PAGE_SIZE)) { + break + } + } + Result.success(Unit) + } + + private suspend fun downloadAuditEvents( + profileId: ProfileIdentifier, + count: Int? = null + ): Result { + val syncedUpTo = localDataSource.latestAuditEventTimestamp(profileId).first() + return remoteDataSource.getAuditEvents( + profileId = profileId, + lastKnownUpdate = syncedUpTo, + count = count + ).mapCatching { fhirBundle -> + val events = fhirBundle.extractResources() + localDataSource.saveAuditEvents(profileId, events) + + events.size + } + } + + fun auditEvents(profileId: ProfileIdentifier) = localDataSource.auditEvents(profileId).flowOn(dispatchers.IO) +} 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..5d7dbabf --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/LocalDataSource.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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 org.hl7.fhir.r4.model.AuditEvent +import kotlin.math.max +import kotlin.math.min + +private const val AUDIT_EVENT_PAGE_SIZE = 25 + +class AuditEventLocalDataSource( + private val realm: Realm +) { + suspend fun saveAuditEvents(profileId: ProfileIdentifier, events: List) { + realm.write { + val profile = requireNotNull( + queryFirst( + "id = $0", + profileId + ) + ) { "No profile with id = $profileId found!" } + + events + .sortedBy { it.recorded.toInstant() } // store from old to new + .forEach { event -> + val entity = copyToRealm( + event.toAuditEntityV1().apply { + this.profile = profile + } + ) + + profile.auditEvents += entity + } + } + } + + 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 = AUDIT_EVENT_PAGE_SIZE, + initialLoadSize = AUDIT_EVENT_PAGE_SIZE * 2, + maxSize = AUDIT_EVENT_PAGE_SIZE * 3 + ), + pagingSourceFactory = { AuditPagingSource(profileId) } + ).flow +} + +fun AuditEvent.toAuditEntityV1() = + AuditEventEntityV1().apply { + id = this@toAuditEntityV1.idElement.idPart + text = this@toAuditEntityV1.text.div.allText() + timestamp = this@toAuditEntityV1.recorded.toInstant().toRealmInstant() + taskId = this@toAuditEntityV1.entity[0].what.referenceElement.idPart + } diff --git a/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/RemoteDataSource.kt b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/RemoteDataSource.kt new file mode 100644 index 00000000..a8b6ed9b --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/protocol/repository/RemoteDataSource.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.protocol.repository + +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 +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +class AuditEventRemoteDataSource( + private val service: ErpService +) { + suspend fun getAuditEvents( + profileId: ProfileIdentifier, + lastKnownUpdate: Instant?, + count: Int? = null, + offset: Int? = null + ) = safeApiCall( + errorMessage = "Error getting all audit events" + ) { + val dateTimeString: String? = + lastKnownUpdate?.let { + "gt${ + it.atOffset(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + }" + } + service.getAuditEvents( + profileId = profileId, + lastKnownDate = dateTimeString, + 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..8a74d328 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/GeneralSettings.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.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() +} 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..9babc1d9 --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/model/SettingsData.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.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 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..089fb63a --- /dev/null +++ b/common/src/commonMain/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepository.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 + }, + 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 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 94% 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 8c1d4075..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 @@ -21,9 +21,8 @@ package de.gematik.ti.erp.app.vau.repository 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 86% 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 7fc7d143..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 @@ -23,22 +23,21 @@ 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() } @@ -47,7 +46,7 @@ class VauRepository @Inject constructor( val ocsp = ocspResult.await().getOrThrow() - Timber.d("...GET cert & ocsp from backend was successful") + 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 90% 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 bd6a2506..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,9 +79,9 @@ class TruststoreUseCase @Inject constructor( idpCertificate: X509CertificateHolder, invalidateStoreOnFailure: Boolean = false ) = lock.withLock { - val timestamp = timeSourceProvider.now() + val timestamp = timeSourceProvider() - Timber.d("Check IDP certificate with truststore") + Napier.d("Check IDP certificate with truststore") val exception = withLoadedStore(timestamp) { store -> try { @@ -118,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) @@ -132,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) @@ -147,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) @@ -174,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, @@ -305,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/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/android/src/release/java/de/gematik/ti/erp/app/di/ReleaseHeadersModule.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/TestDB.kt similarity index 54% rename from android/src/release/java/de/gematik/ti/erp/app/di/ReleaseHeadersModule.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/TestDB.kt index 26980b70..c473295e 100644 --- a/android/src/release/java/de/gematik/ti/erp/app/di/ReleaseHeadersModule.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/TestDB.kt @@ -16,25 +16,28 @@ * */ -package de.gematik.ti.erp.app.di +package de.gematik.ti.erp.app.db -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 java.io.File +import java.nio.file.Files +import kotlin.io.path.absolutePathString +import kotlin.test.AfterTest +import kotlin.test.BeforeTest -@InstallIn(SingletonComponent::class) -@Module -object ReleaseHeadersModule { +private fun createTempDir() = Files.createTempDirectory("realm-db-test").absolutePathString() - @DevelopReleaseHeaderInterceptor - @Provides - fun providesHeaderInterceptor(): Interceptor = Interceptor { chain -> - chain.proceed( - chain.request().newBuilder() - .build() - ) +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..b393edc8 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/DelegatesTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 + +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) + } +} 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/OftenUsedPharmacyEntityV1Test.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/OftenUsedPharmacyEntityV1Test.kt new file mode 100644 index 00000000..d345d676 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/OftenUsedPharmacyEntityV1Test.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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 + +class OftenUsedPharmacyEntityV1Test 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..fe198e3b --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SettingsEntityV1Test.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.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.OftenUsedPharmacyEntityV1 +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 io.realm.kotlin.ext.realmListOf + +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, + OftenUsedPharmacyEntityV1::class + ) + ) + .schemaVersion(0) + .directory(tempDBPath) + .build() + ).also { realm -> + realm.writeBlocking { + copyToRealm( + SettingsEntityV1().apply { + this.pharmacySearch = PharmacySearchEntityV1() + this.password = PasswordEntityV1() + this.oftenUsedPharmacies = realmListOf(OftenUsedPharmacyEntityV1(), OftenUsedPharmacyEntityV1()) + } + ) + } + + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(1, realm.query().count().find()) + assertEquals(2, 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()) + 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..90d7fa12 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/db/entities/v1/SyncedTaskEntityV1Test.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.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 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, + OftenUsedPharmacyEntityV1::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.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(4, realm.query().count().find()) + assertEquals(8, 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()) + 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/PharmacyMapperTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/model/PharmacyMapperTest.kt new file mode 100644 index 00000000..b6f13a84 --- /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/" + +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/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..42b50c22 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/fhir/parser/ConverterTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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" + ) + + 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..aa4634ae --- /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.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 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, + OftenUsedPharmacyEntityV1::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 85% 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 40df3f99..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 @@ -32,7 +31,6 @@ import io.mockk.mockk import io.mockk.spyk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals 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 ) ) @@ -118,21 +112,6 @@ 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`() = runTest { coEvery { idpRepository.loadUncheckedIdpConfiguration() } returns idpConfigNow diff --git a/android/src/test/java/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt similarity index 60% rename from android/src/test/java/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt rename to common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt index 2bc2d241..0db75dec 100644 --- a/android/src/test/java/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpIntegrationTest.kt @@ -18,19 +18,18 @@ package de.gematik.ti.erp.app.idp.usecase -import com.squareup.moshi.Moshi +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.db.entities.ActiveProfile 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.JWSAdapter -import de.gematik.ti.erp.app.idp.api.models.PairingData +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.api.model.X509ArrayAdapter import de.gematik.ti.erp.app.vau.usecase.TruststoreUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -39,29 +38,33 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.spyk -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 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.jose4j.jws.JsonWebSignature -import org.jose4j.jwx.JsonWebStructure import org.junit.Assume import org.junit.Before import org.junit.Test import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory +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 { @@ -78,21 +81,29 @@ class IdpIntegrationTest { 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 moshi = Moshi.Builder().build() - 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) - every { profilesRepository.activeProfile() } returns flowOf(ActiveProfile(id = 0, profileName = "")) coEvery { truststoreUseCase.checkIdpCertificate(any(), any()) } coAnswers {} every { cryptoProvider.signatureInstance() } returns Signature.getInstance("SHA256withECDSA") coEvery { localDataSource.loadIdpInfo() } returns null @@ -110,32 +121,32 @@ class IdpIntegrationTest { .client(client) .baseUrl(BuildKonfig.IDP_SERVICE_URI) .addConverterFactory(JWSConverterFactory()) - .addConverterFactory( - MoshiConverterFactory.create( - moshi.newBuilder().add(JWSAdapter()).add(X509ArrayAdapter()).build() - ) - ) + .addConverterFactory(jsonConverterFactory) .build() .create(IdpService::class.java) idpRepository = spyk( IdpRepository( - moshi = moshi, remoteDataSource = IdpRemoteDataSource(idpService), localDataSource = localDataSource ) ) + idpPairingRepository = spyk( + IdpPairingRepository( + localDataSource = localDataSource + ) + ) + basicUseCase = IdpBasicUseCase( repository = idpRepository, - truststoreUseCase = truststoreUseCase, - profilesRepository = profilesRepository + truststoreUseCase = truststoreUseCase ) useCase = IdpUseCase( repository = idpRepository, + pairingRepository = idpPairingRepository, altAuthUseCase = IdpAlternateAuthenticationUseCase( - moshi = moshi, basicUseCase = basicUseCase, repository = idpRepository, deviceInfo = mockk { @@ -149,7 +160,7 @@ class IdpIntegrationTest { ), profilesRepository = profilesRepository, basicUseCase = basicUseCase, - sharedPreferences = mockk(relaxed = true), + preferences = mockk(relaxed = true), cryptoProvider = cryptoProvider ) } @@ -172,37 +183,51 @@ class IdpIntegrationTest { @Test fun `authenticate with health card`() = runTest { useCase.authenticationFlowWithHealthCard( + profileId = profileId, + cardAccessNumber = cardAccessNumber, healthCardCertificate = { Base64.decode(healthCardCert) }, sign = { sign(it) } ) - coVerify(exactly = 1) { idpRepository.setSingleSignOnToken("", any()) } - coVerify(exactly = 1) { idpRepository.decryptedAccessTokenMap } + coVerify(exactly = 1) { idpRepository.saveSingleSignOnToken(profileId, any()) } + coVerify(exactly = 1) { idpRepository.saveDecryptedAccessToken(profileId, any()) } - assertEquals(true, idpRepository.decryptedAccessTokenMap.value[""]?.isNotEmpty()) + assertEquals(true, idpRepository.decryptedAccessToken(profileId).first()?.isNotEmpty()) } @Test fun `authenticate with health card and get paired devices`() = runTest { - val pairedDevices = useCase.getPairedDevices( + useCase.authenticationFlowWithHealthCard( + profileId = profileId, + scope = IdpScope.BiometricPairing, + cardAccessNumber = cardAccessNumber, healthCardCertificate = { Base64.decode(healthCardCert) }, sign = { sign(it) } ) - println(pairedDevices.entries) - - val moshiAdapter = moshi.adapter(PairingData::class.java) - pairedDevices.entries.forEach { - println(moshiAdapter.fromJson((JsonWebStructure.fromCompactSerialization(it.signedPairingData) as JsonWebSignature).unverifiedPayload)) + 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`() = runTest { + fun `authenticate with key store and get paired devices`() = runTest { val keyPair = KeyPairGenerator.getInstance("EC") .apply { initialize(ECGenParameterSpec("secp256r1")) } .generateKeyPair() @@ -222,6 +247,8 @@ class IdpIntegrationTest { every { cryptoProvider.keyStoreInstance() } returns keyStore useCase.alternatePairingFlowWithSecureElement( + profileId = profileId, + cardAccessNumber = cardAccessNumber, publicKeyOfSecureElementEntry = keyPair.public, aliasOfSecureElementEntry = alias, healthCardCertificate = { @@ -230,56 +257,63 @@ class IdpIntegrationTest { signWithHealthCard = { sign(it) } ) - coEvery { idpRepository.getHealthCardCertificate("") } answers { flowOf(Base64.decode(healthCardCert)) } - coEvery { idpRepository.getAliasOfSecureElementEntry("") } answers { flowOf(alias) } + coEvery { idpRepository.authenticationData(profileId) } answers { + flowOf( + IdpData.AuthenticationData( + IdpData.AlternateAuthenticationWithoutToken( + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = alias, + healthCardCertificate = Base64.decode(healthCardCert) + ) + ) + ) + } - useCase.alternateAuthenticationFlowWithSecureElement("") + useCase.alternateAuthenticationFlowWithSecureElement(profileId = profileId, scope = IdpScope.Default) - coVerify(exactly = 2) { idpRepository.setSingleSignOnToken("", any()) } - coVerify(exactly = 1) { idpRepository.decryptedAccessTokenMap } + coVerify(exactly = 2) { idpRepository.saveSingleSignOnToken(profileId, any()) } + coVerify(exactly = 1) { idpRepository.saveDecryptedAccessToken(profileId, any()) } - assertEquals(true, idpRepository.decryptedAccessTokenMap.value[""]?.isNotEmpty()) - } + assertEquals(true, idpRepository.decryptedAccessToken(profileId).first()?.isNotEmpty()) - @Test - fun `authenticate with key store and get paired devices`() = runTest { - val keyPair = KeyPairGenerator.getInstance("EC") - .apply { initialize(ECGenParameterSpec("secp256r1")) } - .generateKeyPair() + // + // paired devices + // - val keyStore = mockk(relaxed = true) { - every { getEntry(any(), any()) } answers { - mockk { - every { privateKey } returns keyPair.private - } - } - } + useCase.alternateAuthenticationFlowWithSecureElement(profileId = profileId, scope = IdpScope.BiometricPairing) - val alias = ByteArray(32).apply { - Random.nextBytes(this) + coEvery { localDataSource.authenticationData(profileId) } answers { + flowOf( + IdpData.AuthenticationData( + IdpData.AlternateAuthenticationWithoutToken( + cardAccessNumber = cardAccessNumber, + aliasOfSecureElementEntry = alias, + healthCardCertificate = Base64.decode(healthCardCert) + ) + ) + ) } - every { cryptoProvider.keyStoreInstance() } returns keyStore + val aliasBase64 = Base64Url.encode(alias) - useCase.alternatePairingFlowWithSecureElement( - publicKeyOfSecureElementEntry = keyPair.public, - aliasOfSecureElementEntry = alias, - healthCardCertificate = { - Base64.decode(healthCardCert) - }, - signWithHealthCard = { sign(it) } - ) - - coEvery { idpRepository.getHealthCardCertificate("") } answers { flowOf(Base64.decode(healthCardCert)) } - coEvery { idpRepository.getAliasOfSecureElementEntry("") } answers { flowOf(alias) } - - val pairedDevices = useCase.getPairedDevicesWithSecureElement("") + useCase.getPairedDevices(profileId = profileId).getOrThrow().let { pairedDevices -> + println(pairedDevices) + assertTrue { + pairedDevices.any { (_, pairing) -> + pairing.keyAliasOfSecureElement == aliasBase64 + } + } + } - println(pairedDevices.entries) + useCase.deletePairedDevice(profileId = profileId, deviceAlias = aliasBase64) - val moshiAdapter = moshi.adapter(PairingData::class.java) - pairedDevices.entries.forEach { - println(moshiAdapter.fromJson((JsonWebStructure.fromCompactSerialization(it.signedPairingData) as JsonWebSignature).unverifiedPayload)) + 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..8dab373c --- /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.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 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, + OftenUsedPharmacyEntityV1::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..6f91c6c2 --- /dev/null +++ b/common/src/commonTest/kotlin/de/gematik/ti/erp/app/settings/repository/SettingsRepositoryTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2022 gematik GmbH + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the Licence); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 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.entities.v1.task.OftenUsedPharmacyEntityV1 +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, + OftenUsedPharmacyEntityV1::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/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/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/db/converter/ProfileColorConverter.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/db/converter/ProfileColorConverter.kt rename to common/src/desktopMain/kotlin/de/gematik/ti/erp/app/idp/usecase/IdpCryptoProvider.kt index 414c3201..97f57503 100644 --- a/android/src/main/java/de/gematik/ti/erp/app/db/converter/ProfileColorConverter.kt +++ b/common/src/desktopMain/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(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..e8df009d --- /dev/null +++ b/config/detekt/baseline.xml @@ -0,0 +1,614 @@ + + + + + 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:CardWallAuthDialog.kt$@OptIn( ExperimentalAnimationApi::class, ExperimentalCoroutinesApi::class ) @Composable fun CardWallAuthenticationDialog( dialogState: CardWallAuthenticationDialogState = rememberCardWallAuthenticationDialogState(), viewModel: CardWallViewModel, authenticationMethod: CardWallData.AuthenticationMethod, profileId: ProfileIdentifier, cardAccessNumber: String, personalIdentificationNumber: String, troubleShootingEnabled: Boolean = false, allowUserCancellation: Boolean = true, onFinal: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, onUnlockEgk: () -> Unit, onClickTroubleshooting: (() -> Unit)? = null, onStateChange: ((AuthenticationState) -> Unit)? = null ) + ComplexMethod:PharmacySearchScreenComponents.kt$@OptIn( ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class ) @Composable fun PharmacySearchScreen( mainNavController: NavController, onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, viewModel: PharmacySearchViewModel ) + ComplexMethod:RoomModule.kt$private fun migrateRoomToRealm(db: AppDatabase, realm: Realm, appPrefs: SharedPreferences) + ComplexMethod:SyncedTaskEntityV1Test.kt$SyncedTaskEntityV1Test$@Test fun `cascading delete`() + EmptyDefaultConstructor:PrefixedLogger.kt$NapierLogger$() + EmptyFunctionBlock:CardWallInstructionVideo.kt$<no name provided>${} + EmptyFunctionBlock:DebugScreenWrapper.kt${} + EmptyFunctionBlock:Main.kt$LogHandler${ } + EmptyFunctionBlock:RemoteDataSourceTest.kt$RemoteDataSourceTest.<no name provided>${ } + 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:CardWallAuthDialog.kt$@OptIn( ExperimentalAnimationApi::class, ExperimentalCoroutinesApi::class ) @Composable fun CardWallAuthenticationDialog( dialogState: CardWallAuthenticationDialogState = rememberCardWallAuthenticationDialogState(), viewModel: CardWallViewModel, authenticationMethod: CardWallData.AuthenticationMethod, profileId: ProfileIdentifier, cardAccessNumber: String, personalIdentificationNumber: String, troubleShootingEnabled: Boolean = false, allowUserCancellation: Boolean = true, onFinal: () -> Unit, onRetryCan: () -> Unit, onRetryPin: () -> Unit, onUnlockEgk: () -> Unit, onClickTroubleshooting: (() -> Unit)? = null, onStateChange: ((AuthenticationState) -> Unit)? = null ) + LongMethod:CardWallComponents.kt$@Composable fun CardWallScreen( mainNavController: NavController, onResumeCardWall: () -> Unit, profileId: ProfileIdentifier ) + LongMethod:EditShippingContactScreen.kt$@Composable fun EditShippingContactScreen( navController: NavController, taskIds: List<String>, viewModel: PharmacySearchViewModel ) + 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:PharmacyOrderScreen.kt$@Composable fun PharmacyOrderScreen( navController: NavController, taskIds: List<String>, viewModel: PharmacySearchViewModel, onSuccessfullyOrdered: (PharmacyScreenData.OrderOption) -> Unit ) + LongMethod:PharmacySearchScreenComponents.kt$@OptIn( ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class ) @Composable fun PharmacySearchScreen( mainNavController: NavController, onSelectPharmacy: (PharmacyUseCaseData.Pharmacy) -> Unit, viewModel: PharmacySearchViewModel ) + LongMethod:RedeemComponents.kt$@OptIn(ExperimentalMaterialApi::class) @Composable fun RedeemScreen(taskIds: List<String>, navController: NavController) + LongMethod:RoomModule.kt$private fun migrateRoomToRealm(db: AppDatabase, realm: Realm, appPrefs: SharedPreferences) + LongMethod:SyncedTaskEntityV1Test.kt$SyncedTaskEntityV1Test$@Test fun `cascading delete`() + LongParameterList:DebugSettingsViewModel.kt$DebugSettingsViewModel$( visibleDebugTree: VisibleDebugTree, private val endpointHelper: EndpointHelper, private val cardWallUseCase: CardWallUseCase, private val hintUseCase: HintUseCase, private val prescriptionUseCase: PrescriptionUseCase, private val vauRepository: VauRepository, private val idpRepository: IdpRepository, private val idpUseCase: IdpUseCase, private val profilesUseCase: ProfilesUseCase, private val featureToggleManager: FeatureToggleManager, private val pharmacyDirectRedeemUseCase: PharmacyDirectRedeemUseCase, private val dispatchers: DispatchProvider ) + 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:AuthenticationUseCase.kt$AuthenticationUseCase$60 + 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:CardWallInstructionVideo.kt$100 + 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$3f + MagicNumber:OnboardingComponents.kt$8f + 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:PharmacyDetailsScreenComponents.kt$12 + MagicNumber:PharmacyDetailsScreenComponents.kt$14 + MagicNumber:PharmacyDetailsScreenComponents.kt$18 + MagicNumber:PharmacyDetailsScreenComponents.kt$4 + MagicNumber:PharmacyDetailsScreenComponents.kt$6 + MagicNumber:PharmacyDetailsScreenComponents.kt$8 + MagicNumber:PharmacyOrderScreen.kt$0.33f + MagicNumber:PharmacyOrderScreen.kt$1000 + MagicNumber:PharmacySearchModel.kt$Location$180.0 + MagicNumber:PharmacySearchScreenComponents.kt$0.25f + MagicNumber:PharmacySearchScreenComponents.kt$0.4f + MagicNumber:PharmacySearchScreenComponents.kt$0.6f + MagicNumber:PharmacySearchScreenComponents.kt$0.8f + MagicNumber:PharmacySearchScreenComponents.kt$1.5f + MagicNumber:PharmacySearchScreenComponents.kt$100 + MagicNumber:PharmacySearchScreenComponents.kt$1000 + MagicNumber:PharmacySearchScreenComponents.kt$3 + MagicNumber:PharmacySearchScreenComponents.kt$330 + MagicNumber:PrescriptionDetailScreen.kt$1.5f + MagicNumber:PrescriptionDetailScreen.kt$500 + 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:SettingsScreen.kt$200 + MagicNumber:SettingsScreen.kt$4 + MagicNumber:SyncedTaskData.kt$SyncedTaskData.SyncedTask$10 + 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:AppDatabase.kt$<no name provided>$"CREATE INDEX IF NOT EXISTS `index_idpAuthenticationDataEntity_profileName` ON `idpAuthenticationDataEntity_new` (`profileName`)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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 DEFAULT '', `telematicsId` TEXT NOT NULL, `kbvUserId` TEXT NOT NULL, `payload` TEXT, `consumed` INTEGER NOT NULL, PRIMARY KEY(`communicationId`))" + MaxLineLength:AppDatabase.kt$<no name provided>$"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 )" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"CREATE TABLE IF NOT EXISTS `tasks_new` (`taskId` TEXT NOT NULL, `profileName` TEXT NOT NULL, `accessCode` TEXT, `lastModified` TEXT, `expiresOn` TEXT, `acceptUntil` TEXT, `authoredOn` TEXT, `status` TEXT, `scannedOn` TEXT, `scanSessionEnd` TEXT, `nrInScanSession` INTEGER, `redeemedOn` TEXT, `rawKBVBundle` BLOB, PRIMARY KEY(`taskId`))" + MaxLineLength:AppDatabase.kt$<no name provided>$"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)" + MaxLineLength:AppDatabase.kt$<no name provided>$"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`))" + MaxLineLength:AppDatabase.kt$<no name provided>$"INSERT INTO `idpAuthenticationDataEntity_new` (`id`,`singleSignOnToken`, `singleSignOnTokenScope`, `healthCardCertificate`, `aliasOfSecureElementEntry`) select `id`,`singleSignOnToken`, `singleSignOnTokenScope`, `healthCardCertificate`, `aliasOfSecureElementEntry` from `idpAuthenticationDataEntity`" + MaxLineLength:AppDatabase.kt$<no name provided>$"INSERT INTO `medicationDispense_new` (`taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text`) SELECT `taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text` FROM `medicationDispense`" + MaxLineLength:AppDatabase.kt$<no name provided>$"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`" + MaxLineLength:AppDatabase.kt$<no name provided>$"INSERT INTO `tasks_new` (`taskId`, `profileName`, `accessCode`, `lastModified`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `redeemedOn`, `rawKBVBundle`) select `taskId`, `profileName`, `accessCode`, `lastModified`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `redeemedOn`, `rawKBVBundle` FROM `tasks`" + MaxLineLength:AppDatabase.kt$<no name provided>$"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`" + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("ALTER TABLE settings ADD COLUMN `dataProtectionVersionAccepted` TEXT NOT NULL DEFAULT '2021-10-15'") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_filterDeliveryService` INTEGER NOT NULL DEFAULT 0") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("ALTER TABLE settings ADD COLUMN `pharmacySearch_filterOnlineService` INTEGER NOT NULL DEFAULT 0") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("CREATE TABLE IF NOT EXISTS `_new_profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `lastAuthenticated` TEXT, `insurantName` TEXT, `insuranceIdentifier` TEXT, `insuranceName` TEXT, `color` TEXT NOT NULL)") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("CREATE TABLE IF NOT EXISTS `activeProfile` (`id` INTEGER NOT NULL, `profileName` TEXT NOT NULL, PRIMARY KEY(`id`))") + MaxLineLength:AppDatabase.kt$<no name provided>$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 )") + MaxLineLength:AppDatabase.kt$<no name provided>$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 )") + MaxLineLength:AppDatabase.kt$<no name provided>$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`))") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("CREATE TABLE IF NOT EXISTS `idpAuthenticationDataEntity` (`singleSignOnToken` TEXT, `singleSignOnTokenScope` TEXT, `healthCardCertificate` BLOB, `aliasOfSecureElementEntry` BLOB, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))") + MaxLineLength:AppDatabase.kt$<no name provided>$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`))") + MaxLineLength:AppDatabase.kt$<no name provided>$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`))") + MaxLineLength:AppDatabase.kt$<no name provided>$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`))") + MaxLineLength:AppDatabase.kt$<no name provided>$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`))") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `insuranceNumber` TEXT)") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("CREATE TABLE IF NOT EXISTS `safetynetattestations` (`id` INTEGER NOT NULL, `jws` TEXT NOT NULL, ourNonce BLOB NOT NULL, PRIMARY KEY(`id`))") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("INSERT INTO `_new_profiles` (`lastAuthenticated`, `insurantName`,`color`,`name`,`insuranceName`,`id`,`insuranceIdentifier`) SELECT `lastAuthenticated`, `insurantName`,`color`,`name`,`insuranceName`,`id`,`insuranceIdentifier` FROM `profiles`") + MaxLineLength:AppDatabase.kt$<no name provided>$database.execSQL("INSERT OR REPLACE INTO `activeProfile` (`id`, `profileName`) VALUES (0, '$DEFAULT_PROFILE_NAME')") + MaxLineLength:AppDatabaseMigrationTest.kt$AppDatabaseMigrationTest$"INSERT INTO `communications` (`communicationId`, `profile`, `time`, `taskId`, `telematicsId`, `kbvUserId`, `payload`, `consumed`)" + MaxLineLength:AppDatabaseMigrationTest.kt$AppDatabaseMigrationTest$"INSERT INTO `medicationDispense` (`taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text`, `type`)" + MaxLineLength:AppDatabaseMigrationTest.kt$AppDatabaseMigrationTest$"INSERT INTO `tasks` (`taskId`, `profileName`, `accessCode`, `lastModified`, `organization`, `medicationText`, `expiresOn`, `acceptUntil`, `authoredOn`, `status`, `scannedOn`, `scanSessionEnd`, `nrInScanSession`, `scanSessionEnd`, `scanSessionName`, `redeemedOn`, `rawKBVBundle`)" + MaxLineLength:AppDatabaseMigrationTest.kt$AppDatabaseMigrationTest$db.query("SELECT `taskId`, `patientIdentifier`, `uniqueIdentifier`, `wasSubstituted`, `dosageInstruction`, `performer`, `whenHandedOver`, `text`, `type` FROM `medicationDispense`") + 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:CardWallComponents.kt$var personalIdentificationNumber by rememberSaveable(state.personalIdentificationNumber) { mutableStateOf(state.personalIdentificationNumber) } + MaxLineLength:CardWallComponents.kt$var selectedAuthenticationMethod by rememberSaveable(state.selectedAuthenticationMethod) { mutableStateOf(state.selectedAuthenticationMethod) } + MaxLineLength:CardWallInstructionVideo.kt$val textTrackIndex = player.trackInfo.indexOfFirst { it.trackType == MediaPlayer.TrackInfo.MEDIA_TRACK_TYPE_TIMEDTEXT } + 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:CardWallViewModel.kt$CardWallViewModel$is IdpData.AlternateAuthenticationWithoutToken -> CardWallData.AuthenticationMethod.Alternative + 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:LazyFhirParser.kt$LazyFhirParser$override + 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:OnboardingComponents.kt$currentPage == SECURE_APP_PAGE && secureMethod is OnboardingSecureAppMethod.Password -> secureMethod.checkedPassword != null + MaxLineLength:PharmacySearchScreenComponents.kt$HintTextActionButton + MaxLineLength:PharmacySearchScreenComponents.kt$body = { Text(stringResource(R.string.search_enable_location_hint_info)) } + MaxLineLength:PharmacySearchScreenComponents.kt$if + MaxLineLength:PharmacySearchScreenComponents.kt$title = { Text(stringResource(R.string.search_enable_location_hint_header)) } + MaxLineLength:PharmacySearchUseCase.kt$PharmacySearchUseCase$locationMode = if (it.locationEnabled) PharmacyUseCaseData.LocationMode.EnabledWithoutPosition else PharmacyUseCaseData.LocationMode.Disabled + MaxLineLength:PharmacySearchUseCase.kt$PharmacySearchUseCase$openingHours = (pharmacy.provides.find { it is LocalPharmacyService } as LocalPharmacyService).openingHours + MaxLineLength:PharmacySearchUseCase.kt$PharmacySearchUseCase.PharmacyPagingSource$override + MaxLineLength:PharmacySearchUseCase.kt$PharmacySearchUseCase.PharmacyPagingSource$override suspend + MaxLineLength:PharmacySearchViewModel.kt$PharmacySearchViewModel$it.copy(locationMode = if (anyLocationPermissionGranted(context)) it.locationMode else PharmacyUseCaseData.LocationMode.Disabled) + MaxLineLength:PharmacySearchViewModel.kt$PharmacySearchViewModel$searchData.filter.deliveryService && pharmacy.provides.any { it is DeliveryPharmacyService } -> true + MaxLineLength:PrescriptionDetailScreen.kt$"${prescription.medication.normSizeCode} - ${App.strings.normSizeMapping()[prescription.medication.normSizeCode]?.invoke()}" + MaxLineLength:PrescriptionDetailScreen.kt$if + 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:SettingsScreen.kt$if + MaxLineLength:SettingsScreen.kt$onClickEdit = { navController.navigate(SettingsNavigationScreens.EditProfile.path(profileId = profile.id)) } + 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())) + SpreadOperator:RoomModule.kt$(*migrations) + 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: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 + TooGenericExceptionThrown:RemoteDataSourceTest.kt$RemoteDataSourceTest$throw RuntimeException(e) + TooManyFunctions:AppDependenciesPlugin.kt$App$App + TooManyFunctions:Common.kt$de.gematik.ti.erp.app.utils.compose.Common.kt + TooManyFunctions:Converter.kt$de.gematik.ti.erp.app.fhir.Converter.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:LazyFhirParser.kt$LazyFhirParser : IParser + TooManyFunctions:LocalDataSource.kt$de.gematik.ti.erp.app.prescription.repository.LocalDataSource.kt + TooManyFunctions:OnboardingComponents.kt$de.gematik.ti.erp.app.onboarding.ui.OnboardingComponents.kt + TooManyFunctions:PrescriptionDetailScreen.kt$de.gematik.ti.erp.app.prescription.detail.ui.PrescriptionDetailScreen.kt + 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:HintUseCase.kt$private const val preferencePrefix = "CancellableHint_" + TopLevelPropertyNaming:IdpRemoteDataSource.kt$private const val defaultScope = "e-rezept openid" + TopLevelPropertyNaming:IdpRemoteDataSource.kt$private const val pairingScope = "pairing openid" + TopLevelPropertyNaming:PasswordScreen.kt$private const val minimalPasswordScore = 2 + TopLevelPropertyNaming:PharmacySearchViewModel.kt$private const val waitForLocationUpdate = 5000L + 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:AppDatabaseMigrationTest.kt$AppDatabaseMigrationTest$private val TEST_DB = "migration-test" + 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..1f1dfe3b --- /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: false + 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/build.gradle.kts b/desktop/build.gradle.kts index 39e85bc9..a803e9d6 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,35 +61,27 @@ 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")) - } - kotlinX { - implementation(coroutines("core")) + compileOnly(paging("common-ktx")) } 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/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..94021748 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( 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/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 f70d7b9d..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 @@ -324,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( @@ -372,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/PrescriptionRepository.kt b/desktop/src/jvmMain/kotlin/de/gematik/ti/erp/app/prescription/repository/PrescriptionRepository.kt index 7d76e070..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,12 +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/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 caa8723c..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 @@ -38,7 +38,7 @@ class PrescriptionMapper { acceptUntil = task.acceptUntil, organization = task.organization, authoredOn = task.authoredOn, - redeemedOn = redeemedOn, + redeemedOn = redeemedOn ) fun mapSimpleTaskDetailed(task: SimpleTask, dispenses: List) = @@ -50,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/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 775714f8..219612b2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,17 +20,13 @@ android.enableJetifier=true # 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.4 GMTIK/eRezeptApp +USER_AGENT=eRp-App-Android/1.4.2 GMTIK/eRezeptApp # DATA_PROTECTION_LAST_UPDATED = 2022-01-06 # @@ -73,6 +69,10 @@ ERP_API_KEY_GOOGLE_RU= 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 # @@ -81,14 +81,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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 961acabe..09bddfcf 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.4.2-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 e89a09c4..72e86d27 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,32 +65,32 @@ class AppDependenciesPlugin : Plugin { object Dependencies { const val MinimumSdkVersion = 24 - const val CompileSdkVersion = 31 + const val CompileSdkVersion = 32 const val TargetSdkVersion = 31 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" @@ -97,36 +98,37 @@ class AppDependenciesPlugin : Plugin { 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 +137,18 @@ 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" @@ -166,12 +166,13 @@ 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.4.1" + const val realm = "io.realm.kotlin:library-base:1.0.1" object Test { val roomTesting = room("testing") } } - internal const val composeVersion = "1.1.0-rc01" + const val composeVersion = "1.2.0" object Compose { const val compiler = "androidx.compose.compiler:compiler:$composeVersion" @@ -187,7 +188,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.1-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 +197,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 +269,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