diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c47ef47fd..f85ac0b82 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,9 +2,9 @@ name: Android Check Build on: push: - branches: [ master, develop ] + branches: [ master, develop, feature/v2 ] pull_request: - branches: [ master, develop ] + branches: [ master, develop, feature/v2 ] jobs: build: diff --git a/.github/workflows/appcenter_test.yml b/.github/workflows/appcenter_test.yml deleted file mode 100644 index 1bf3f23f2..000000000 --- a/.github/workflows/appcenter_test.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Android Build - -on: - push: - branches: [ master, develop ] - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Build with Gradle - uses: ./.github/actions/gradle_docker - with: - gradle-cmd: assembleTeschtRelease -PkeystorePassword=${{secrets.KEYSTORE_PASSWORD}} -PkeyAliasPassword=${{secrets.KEY_ALIAS_PASSWORD}} - - name: upload artefact to App Center - uses: wzieba/AppCenter-Github-Action@v1.0.0 - with: - appName: ${{secrets.APPCENTER_ORGANIZATION}}/${{secrets.APPCENTER_APP_TEST}} - token: ${{secrets.APPCENTER_API_TOKEN}} - group: Internal - file: app/build/outputs/apk/tescht/release/app-tescht-release.apk diff --git a/Dockerfile b/Dockerfile index 777679f88..803636bd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ FROM gradle:6.6.1-jdk8 ENV ANDROID_SDK_URL https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip -ENV ANDROID_BUILD_TOOLS_VERSION 29.0.3 +ENV ANDROID_BUILD_TOOLS_VERSION 30.0.1 ENV ANDROID_HOME /usr/local/android-sdk-linux -ENV ANDROID_VERSION 29 +ENV ANDROID_VERSION 30 ENV PATH ${PATH}:${ANDROID_HOME}/tools:${ANDROID_HOME}/platform-tools RUN mkdir "$ANDROID_HOME" .android && \ diff --git a/README.md b/README.md index 3b6ddec41..c7a986f34 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# SwissCovid: DP3T Android App for Switzerland +

SwissCovid Android App

+
+
+ +
+
+ + + Download on the PlayStore + +
+
-[![License: MPL 2.0](https://img.shields.io/badge/License-MPL%202.0-brightgreen.svg)](https://github.com/DP-3T/dp3t-app-android-ch/blob/master/LICENSE) -![Android Build](https://github.com/DP-3T/dp3t-app-android-ch/workflows/Android%20Build/badge.svg) +[![License: MPL 2.0](https://img.shields.io/badge/License-MPL%202.0-brightgreen.svg)](https://github.com/SwissCovid/swisscovid-app-android/blob/master/LICENSE) +![Android Build](https://github.com/SwissCovid/swisscovid-app-android/workflows/Android%20Build/badge.svg) +SwissCovid is the official contact tracing app of Switzerland. The app can be installed from the [Google Play Store](https://play.google.com/store/apps/details?id=ch.admin.bag.dp3t). The SwissCovid 2.0 app uses two types of contact tracing to prevent the spread of COVID-19. -## DP3T -The Decentralised Privacy-Preserving Proximity Tracing (DP-3T) project is an open protocol for COVID-19 proximity tracing using Bluetooth Low Energy functionality on mobile devices that ensures personal data and computation stays entirely on an individual's phone. It was produced by a core team of over 25 scientists and academic researchers from across Europe. It has also been scrutinized and improved by the wider community. +With proximity tracing close contacts are detected using the bluetooth technology. For this the [DP3T Android SDK](https://github.com/DP-3T/dp3t-sdk-android) is used that builds on top of the Google & Apple Exposure Notifications. This feature is called SwissCovid encounters. -DP-3T is a free-standing effort started at EPFL and ETHZ that produced this protocol and that is implementing it in an open-sourced app and server. +With presence tracing people that are at the same venue at the same time are detected. For this the [CrowdNotifier Android SDK](https://github.com/CrowdNotifier/crowdnotifier-sdk-android) is used that provides a secure, decentralized, privacy-preserving presence tracing system. This feature is called SwissCovid Check-in. - -## Introduction -This is a COVID-19 tracing client using the [DP3T Android SDK](https://github.com/DP-3T/dp3t-sdk-android). It is based on the previously released demo app, but uses the newest version of the SDK. This project will be released as the official COVID-19 tracing solution for Switzerland, therefore UX, messages and flows are optimized for this specific case. Nevertheless, the source code should be a solid foundation to build a similar app for other countries and demostrate how the SDK can be used in a real app. -The app design, UX and implementation was done by [Ubique](https://www.ubique.ch?app=github). -

- - - - -

+Please see the [SwissCovid documentation repository](https://github.com/SwissCovid/swisscovid-doc) for more details. ## Contribution Guide @@ -29,18 +31,19 @@ Bugs or potential problems should be reported using Github issues. We welcome al Platform independent UX and design discussions should be reported in [dp3t-ux-screenflows-ch](https://github.com/DP-3T/dp3t-ux-screenflows-ch) ## Repositories -* Android SDK & Calibration app: [dp3t-sdk-android](https://github.com/DP-3T/dp3t-sdk-android) -* iOS SDK & Calibration app: [dp3t-sdk-ios](https://github.com/DP-3T/dp3t-sdk-ios) -* Android App: [dp3t-app-android](https://github.com/DP-3T/dp3t-app-android-ch) -* iOS App: [dp3t-app-ios](https://github.com/DP-3T/dp3t-app-ios-ch) -* Backend SDK: [dp3t-sdk-backend](https://github.com/DP-3T/dp3t-sdk-backend) -* UX & Screenflows [dp3t-ux-screenflows-ch](https://github.com/DP-3T/dp3t-ux-screenflows-ch) - - -## Further Documentation -The full set of documents for DP3T is at https://github.com/DP-3T/documents. Please refer to the technical documents and whitepapers for a description of the implementation. - -A description of the usage of the Google Exposure Notifcation API can be found [here](https://github.com/DP-3T/dp3t-sdk-android/blob/master/EXPOSURE_NOTIFICATION_API_USAGE.md). +* Android App: [swisscovid-app-android](https://github.com/SwissCovid/swisscovid-app-android) +* iOS App: [swisscovid-app-ios](https://github.com/SwissCovid/swisscovid-app-ios) +* CovidCode Web-App: [CovidCode-UI](https://github.com/admin-ch/CovidCode-UI) +* CovidCode Backend: [CovidCode-Service](https://github.com/admin-ch/CovidCode-service) +* Config Backend: [swisscovid-config-backend](https://github.com/SwissCovid/swisscovid-config-backend) +* Additional Info Backend: [swisscovid-additionalinfo-backend](https://github.com/SwissCovid/swisscovid-additionalinfo-backend) +* QR Code Landingpage: [swisscovid-qr-landingpage](https://github.com/SwissCovid/swisscovid-qr-landingpage) +* DP3T Android SDK & Calibration app: [dp3t-sdk-android](https://github.com/DP-3T/dp3t-sdk-android) +* DP3T iOS SDK & Calibration app: [dp3t-sdk-ios](https://github.com/DP-3T/dp3t-sdk-ios) +* DP3T Backend SDK: [dp3t-sdk-backend](https://github.com/DP-3T/dp3t-sdk-backend) +* CrowdNotifier Android SDK: [crowdnotifier-sdk-android](https://github.com/CrowdNotifier/crowdnotifier-sdk-android) +* CrowdNotifier iOS SDK: [crowdnotifier-sdk-ios](https://github.com/CrowdNotifier/crowdnotifier-sdk-ios) +* CrowdNotifier Backend: [swisscovid-cn-backend](https://github.com/SwissCovid/swisscovid-cn-backend) ## Installation and Building diff --git a/app/build.gradle b/app/build.gradle index dbc046255..2ff71f802 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,37 +12,20 @@ plugins { id 'com.android.application' id 'kotlin-android' id 'org.sonarqube' version '2.8' + } apply from: 'backend_certs.gradle' -ext.readProperty = { paramName -> readPropertyWithDefault(paramName, null) } -ext.readPropertyWithDefault = { paramName, defaultValue -> - if (project.hasProperty(paramName)) { - return project.getProperties().get(paramName) - } else { - Properties properties = new Properties() - if (project.rootProject.file('local.properties').exists()) { - properties.load(project.rootProject.file('local.properties').newDataInputStream()) - } - if (properties.getProperty(paramName) != null) { - return properties.getProperty(paramName) - } else { - return defaultValue - } - } -} - android { compileSdkVersion 30 - buildToolsVersion "29.0.3" defaultConfig { applicationId "ch.admin.bag.dp3t" minSdkVersion 23 targetSdkVersion 30 - versionCode 15000 - versionName "1.5.0" + versionCode 20002 + versionName "2.0.0" resConfigs "en", "fr", "de", "it", "pt", "es", "sq", "bs", "hr", "sr", "rm", "tr", "ti" buildConfigField "long", "BUILD_TIME", readPropertyWithDefault('buildTimestamp', System.currentTimeMillis()) + 'L' @@ -68,20 +51,13 @@ android { buildConfigField 'String', 'BUCKET_URL', '"https://www.pt-d.bfs.admin.ch/"' buildConfigField 'String', 'REPORT_URL', '"https://www.pt1-d.bfs.admin.ch/"' buildConfigField 'String', 'STATS_URL', '"https://www.pt-d.bfs.admin.ch/"' + buildConfigField 'String', 'PUBLISHED_CROWDNOTIFIER_KEYS_BASE_URL', '"https://www.pt-d.bfs.admin.ch/"' + buildConfigField "String", "ENTRY_QR_CODE_HOST", '"qr-d.swisscovid.ch"' + buildConfigField "String", "QR_MASTER_PUBLIC_KEY_BASE_64", '"lW5voTRVR-jgYMiWLd04hjvyyFQG7QOyBLw0D7XbASlqlg0AviQMqgjbABZk9PcCip27szrqFyv_1YtKZE8eyzt7vtN4qKfJdWrItLRzRtjb83piN3cDt_yNo7siohQV"' buildConfigField 'String', 'CONFIG_CERTIFICATE', "\"${project.backend_certs.dev.CONFIG_CERTIFICATE}\"" buildConfigField 'String', 'BUCKET_PUBLIC_KEY', "\"${project.backend_certs.dev.BUCKET_PUBLIC_KEY}\"" applicationIdSuffix '.dev' - } - tescht { - buildConfigField 'boolean', 'DEV_HISTORY', 'true' - buildConfigField 'String', 'AUTH_CODE_URL', '"https://codegen-service-t.bag.admin.ch/"' - buildConfigField 'String', 'CONFIG_URL', '"https://www.pt-t.bfs.admin.ch/"' - buildConfigField 'String', 'BUCKET_URL', '"https://www.pt-t.bfs.admin.ch/"' - buildConfigField 'String', 'REPORT_URL', '"https://www.pt1-t.bfs.admin.ch/"' - buildConfigField 'String', 'STATS_URL', '"https://www.pt-t.bfs.admin.ch/"' - buildConfigField 'String', 'CONFIG_CERTIFICATE', "\"${project.backend_certs.test.CONFIG_CERTIFICATE}\"" - buildConfigField 'String', 'BUCKET_PUBLIC_KEY', "\"${project.backend_certs.test.BUCKET_PUBLIC_KEY}\"" - applicationIdSuffix '.test' + manifestPlaceholders = [qrCodeHostName: "qr-d.swisscovid.ch"] } abnahme { buildConfigField 'boolean', 'DEV_HISTORY', 'true' @@ -90,9 +66,13 @@ android { buildConfigField 'String', 'BUCKET_URL', '"https://www.pt-a.bfs.admin.ch/"' buildConfigField 'String', 'REPORT_URL', '"https://www.pt1-a.bfs.admin.ch/"' buildConfigField 'String', 'STATS_URL', '"https://www.pt-a.bfs.admin.ch/"' + buildConfigField 'String', 'PUBLISHED_CROWDNOTIFIER_KEYS_BASE_URL', '"https://www.pt-a.bfs.admin.ch/"' + buildConfigField "String", "ENTRY_QR_CODE_HOST", '"qr-a.swisscovid.ch"' + buildConfigField "String", "QR_MASTER_PUBLIC_KEY_BASE_64", '"RHk4TO95YVnihWuCSVnbQ3ow-AcO-BXZ9caWeO_GP7o-vEiowJXTkKSUAf77n4oAmzshDvFVLkQFsA72JfBcaC8QSIT9owk83tnDuwpUuDfFgiBq_KXqcA2UcxaInmQS"' buildConfigField 'String', 'CONFIG_CERTIFICATE', "\"${project.backend_certs.abnahme.CONFIG_CERTIFICATE}\"" buildConfigField 'String', 'BUCKET_PUBLIC_KEY', "\"${project.backend_certs.abnahme.BUCKET_PUBLIC_KEY}\"" applicationIdSuffix '.abnahme' + manifestPlaceholders = [qrCodeHostName: "qr-a.swisscovid.ch"] } prod { buildConfigField 'boolean', 'DEV_HISTORY', 'false' @@ -101,8 +81,12 @@ android { buildConfigField 'String', 'BUCKET_URL', '"https://www.pt.bfs.admin.ch/"' buildConfigField 'String', 'REPORT_URL', '"https://www.pt1.bfs.admin.ch/"' buildConfigField 'String', 'STATS_URL', '"https://www.pt.bfs.admin.ch/"' + buildConfigField 'String', 'PUBLISHED_CROWDNOTIFIER_KEYS_BASE_URL', '"https://www.pt.bfs.admin.ch/"' + buildConfigField "String", "ENTRY_QR_CODE_HOST", '"qr.swisscovid.ch"' + buildConfigField "String", "QR_MASTER_PUBLIC_KEY_BASE_64", '"mAofNDGOKcmVJt4fo7kWruvFRee2g0irAfIdjH6EFyd6vHY_uTb6ZXOf9eFVzTUEiZLDLL-Q2w8kZkPscWyXi3X59zuqeI25qloOF-sowHo1_-HiH7Z_4COOA030mm8J"' buildConfigField 'String', 'CONFIG_CERTIFICATE', "\"${project.backend_certs.prod.CONFIG_CERTIFICATE}\"" buildConfigField 'String', 'BUCKET_PUBLIC_KEY', "\"${project.backend_certs.prod.BUCKET_PUBLIC_KEY}\"" + manifestPlaceholders = [qrCodeHostName: "qr.swisscovid.ch"] } log { buildConfigField 'boolean', 'DEV_HISTORY', 'true' @@ -111,8 +95,12 @@ android { buildConfigField 'String', 'BUCKET_URL', '"https://www.pt.bfs.admin.ch/"' buildConfigField 'String', 'REPORT_URL', '"https://www.pt1.bfs.admin.ch/"' buildConfigField 'String', 'STATS_URL', '"https://www.pt.bfs.admin.ch/"' + buildConfigField 'String', 'PUBLISHED_CROWDNOTIFIER_KEYS_BASE_URL', '"https://www.pt.bfs.admin.ch/"' + buildConfigField "String", "ENTRY_QR_CODE_HOST", '"qr.swisscovid.ch"' + buildConfigField "String", "QR_MASTER_PUBLIC_KEY_BASE_64", '"mAofNDGOKcmVJt4fo7kWruvFRee2g0irAfIdjH6EFyd6vHY_uTb6ZXOf9eFVzTUEiZLDLL-Q2w8kZkPscWyXi3X59zuqeI25qloOF-sowHo1_-HiH7Z_4COOA030mm8J"' buildConfigField 'String', 'CONFIG_CERTIFICATE', "\"${project.backend_certs.prod.CONFIG_CERTIFICATE}\"" buildConfigField 'String', 'BUCKET_PUBLIC_KEY', "\"${project.backend_certs.prod.BUCKET_PUBLIC_KEY}\"" + manifestPlaceholders = [qrCodeHostName: "qr.swisscovid.ch"] } } @@ -120,7 +108,7 @@ android { release { storeFile file(readPropertyWithDefault('keystoreFile', 'testKeystore')) storePassword readProperty('keystorePassword') - keyAlias 'keyAlias' + keyAlias readPropertyWithDefault('keyAlias', 'keyAlias') keyPassword readProperty('keyAliasPassword') } } @@ -137,13 +125,17 @@ android { compileOptions { sourceCompatibility = 1.8 targetCompatibility = 1.8 + coreLibraryDesugaringEnabled true } kotlinOptions { jvmTarget = "1.8" } -} + buildFeatures { + viewBinding true + } +} sonarqube { properties { @@ -154,35 +146,56 @@ sonarqube { } } + dependencies { + + implementation project(":common") + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' - def dp3t_sdk_version = '2.2.0' + def dp3t_sdk_version = '2.3.0' devImplementation "org.dpppt:dp3t-sdk-android:$dp3t_sdk_version-calibration" - teschtImplementation "org.dpppt:dp3t-sdk-android:$dp3t_sdk_version" abnahmeImplementation "org.dpppt:dp3t-sdk-android:$dp3t_sdk_version" prodImplementation "org.dpppt:dp3t-sdk-android:$dp3t_sdk_version" logImplementation "org.dpppt:dp3t-sdk-android:$dp3t_sdk_version" implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'androidx.fragment:fragment:1.3.2' - implementation 'androidx.lifecycle:lifecycle-viewmodel:2.2.0' - implementation 'androidx.lifecycle:lifecycle-livedata:2.2.0' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0' + implementation 'androidx.fragment:fragment-ktx:1.3.3' + implementation "androidx.activity:activity-ktx:1.2.3" + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.viewpager2:viewpager2:1.0.0' - implementation 'androidx.security:security-crypto:1.0.0-rc03' + implementation 'androidx.security:security-crypto:1.0.0' implementation 'androidx.work:work-runtime-ktx:2.5.0' + implementation "androidx.datastore:datastore:1.0.0-beta01" implementation 'io.reactivex.rxjava3:rxandroid:3.0.0' implementation 'io.reactivex.rxjava3:rxjava:3.0.0' implementation 'com.google.android.material:material:1.2.1' implementation 'com.google.android.gms:play-services-base:17.5.0' - + implementation 'com.google.protobuf:protobuf-lite:3.0.1' + implementation('com.squareup.retrofit2:converter-protobuf:2.9.0') { transitive = false } implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + // QR code scanning + def camerax_version = "1.0.0-rc04" + implementation "androidx.camera:camera-camera2:${camerax_version}" + implementation "androidx.camera:camera-lifecycle:${camerax_version}" + implementation "androidx.camera:camera-view:1.0.0-alpha23" + //DO NOT UPDATE ZXING TO 3.4.0! (3.4.0 is not compatible with API Level 23 or lower) + implementation "com.google.zxing:core:3.3.0" + + implementation 'androidx.biometric:biometric:1.1.0' + + implementation 'io.github.ShawnLin013:number-picker:2.4.13' + + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:core:1.3.0' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 244ed4eab..319c99004 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -23,4 +23,6 @@ -dontobfuscate -keep class ch.admin.bag.dp3t.inform.models.** { *; } --keep class ch.admin.bag.dp3t.networking.models.** { *; } \ No newline at end of file +-keep class ch.admin.bag.dp3t.networking.models.** { *; } + +-keep class com.sun.jna.** { *; } diff --git a/app/src/androidTest/java/ch/admin/bag/dp3t/AutoCheckoutTest.java b/app/src/androidTest/java/ch/admin/bag/dp3t/AutoCheckoutTest.java new file mode 100644 index 000000000..b8ad970ab --- /dev/null +++ b/app/src/androidTest/java/ch/admin/bag/dp3t/AutoCheckoutTest.java @@ -0,0 +1,145 @@ +package ch.admin.bag.dp3t; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import java.io.IOException; + +import org.crowdnotifier.android.sdk.model.VenueInfo; +import org.crowdnotifier.android.sdk.utils.QrUtils; +import org.dpppt.android.sdk.internal.logger.LogLevel; +import org.dpppt.android.sdk.internal.logger.Logger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import ch.admin.bag.dp3t.checkin.models.CheckInState; +import ch.admin.bag.dp3t.checkin.models.DiaryEntry; +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage; +import ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper; +import ch.admin.bag.dp3t.extensions.VenueInfoExtensionsKt; +import ch.admin.bag.dp3t.storage.SecureStorage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public class AutoCheckoutTest { + + Context context; + DiaryStorage diaryStorage; + VenueInfo checkinVenueInfo; + + @Before + public void setup() throws IOException, QrUtils.InvalidQRCodeFormatException { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + Logger.init(context, LogLevel.DEBUG); + diaryStorage = DiaryStorage.getInstance(context); + diaryStorage.clear(); + + checkinVenueInfo = QrUtils.getVenueInfoFromQrCode("CAQSFAgEEgRUZXN0IL3drYYGKL29nZ4mGoYBCAQSYJVub6E0VUfo4GDIli3dOIY78shUBu0DsgS8NA-12wEpapYNAL4kDKoI2wAWZPT3Aoqdu7M66hcr_9WLSmRPHss7e77TeKinyXVqyLS0c0bY2_N6Yjd3A7f8jaO7IqIUFRogzt1hjKGnZoXWLtf-Oqmx755rZDjLagUig00M4lBkfFAiHwgEIIDo3Q0ogNzMFDDg1AMwgN3bATCAurcDMID07gY"); + } + + @Test + public void checkAutoCheckoutWithEmptyDiary(){ + + long checkinTime = System.currentTimeMillis()-13*60*60*1000L; + CheckInState checkInState = new CheckInState(true, checkinVenueInfo, checkinTime, -1, -1); + CrowdNotifierReminderHelper.autoCheckoutIfNecessary(context, checkInState); + + // There should be exactly one entry in the diary + assertEquals(1, diaryStorage.getEntries().size()); + assertEquals(checkinTime, diaryStorage.getEntries().get(0).getCheckInTime()); + assertEquals(checkinTime+ VenueInfoExtensionsKt.getAutoCheckoutDelay(checkinVenueInfo), diaryStorage.getEntries().get(0).getCheckOutTime()); + } + + @Test + public void checkAutoCheckoutWithoutOverlap(){ + + long checkinTime = System.currentTimeMillis()-13*60*60*1000L; + + diaryStorage.addEntry(new DiaryEntry(0, checkinTime-60*60*1000L, checkinTime, checkinVenueInfo)); + + CheckInState checkInState = new CheckInState(true, checkinVenueInfo, checkinTime, -1, -1); + CrowdNotifierReminderHelper.autoCheckoutIfNecessary(context, checkInState); + + // There should be exactly one entry in the diary + assertEquals(2, diaryStorage.getEntries().size()); + assertEquals(checkinTime, diaryStorage.getEntries().get(1).getCheckInTime()); + assertEquals(checkinTime+ VenueInfoExtensionsKt.getAutoCheckoutDelay(checkinVenueInfo), diaryStorage.getEntries().get(1).getCheckOutTime()); + } + + @Test + public void checkAutoCheckoutWithOneOverlap(){ + + long checkinTime = System.currentTimeMillis()-13*60*60*1000L; + long overlapCheckin = checkinTime+60*60*1000L; + long overlapCheckout = overlapCheckin+60*60*1000L; + + diaryStorage.addEntry(new DiaryEntry(0, overlapCheckin, overlapCheckout, checkinVenueInfo)); + + CheckInState checkInState = new CheckInState(true, checkinVenueInfo, checkinTime, -1, -1); + CrowdNotifierReminderHelper.autoCheckoutIfNecessary(context, checkInState); + + // There should be exactly one entry in the diary + assertEquals(3, diaryStorage.getEntries().size()); + assertEquals(checkinTime, diaryStorage.getEntries().get(1).getCheckInTime()); + assertEquals(overlapCheckin, diaryStorage.getEntries().get(1).getCheckOutTime()); + assertEquals(overlapCheckout, diaryStorage.getEntries().get(2).getCheckInTime()); + assertEquals(checkinTime+ VenueInfoExtensionsKt.getAutoCheckoutDelay(checkinVenueInfo), diaryStorage.getEntries().get(2).getCheckOutTime()); + } + + @Test + public void checkAutoCheckoutWithTwoOverlapsNoGap(){ + + long checkinTime = System.currentTimeMillis()-13*60*60*1000L; + long overlap1Checkin = checkinTime+60*60*1000L; + long overlap1Checkout = overlap1Checkin+60*60*1000L; + long overlap2Checkin = overlap1Checkout; + long overlap2Checkout = overlap1Checkout+2*60*60*1000L; + + + diaryStorage.addEntry(new DiaryEntry(0, overlap1Checkin, overlap1Checkout, checkinVenueInfo)); + diaryStorage.addEntry(new DiaryEntry(1, overlap2Checkin, overlap2Checkout, checkinVenueInfo)); + + CheckInState checkInState = new CheckInState(true, checkinVenueInfo, checkinTime, -1, -1); + CrowdNotifierReminderHelper.autoCheckoutIfNecessary(context, checkInState); + + // There should be exactly one entry in the diary + assertEquals(4, diaryStorage.getEntries().size()); + assertEquals(checkinTime, diaryStorage.getEntries().get(2).getCheckInTime()); + assertEquals(overlap1Checkin, diaryStorage.getEntries().get(2).getCheckOutTime()); + assertEquals(overlap2Checkout, diaryStorage.getEntries().get(3).getCheckInTime()); + assertEquals(checkinTime+ VenueInfoExtensionsKt.getAutoCheckoutDelay(checkinVenueInfo), diaryStorage.getEntries().get(3).getCheckOutTime()); + + } + + @Test + public void checkAutoCheckoutWithTwoOverlapsWithGap(){ + + long checkinTime = System.currentTimeMillis()-13*60*60*1000L; + long overlap1Checkin = checkinTime+60*60*1000L; + long overlap1Checkout = overlap1Checkin+60*60*1000L; + long overlap2Checkin = overlap1Checkout+60*60*1000L; + long overlap2Checkout = overlap1Checkout+2*60*60*1000L; + + + diaryStorage.addEntry(new DiaryEntry(0, overlap1Checkin, overlap1Checkout, checkinVenueInfo)); + diaryStorage.addEntry(new DiaryEntry(1, overlap2Checkin, overlap2Checkout, checkinVenueInfo)); + + CheckInState checkInState = new CheckInState(true, checkinVenueInfo, checkinTime, -1, -1); + CrowdNotifierReminderHelper.autoCheckoutIfNecessary(context, checkInState); + + // There should be exactly one entry in the diary + assertEquals(5, diaryStorage.getEntries().size()); + assertEquals(checkinTime, diaryStorage.getEntries().get(2).getCheckInTime()); + assertEquals(overlap1Checkin, diaryStorage.getEntries().get(2).getCheckOutTime()); + assertEquals(overlap1Checkout, diaryStorage.getEntries().get(3).getCheckInTime()); + assertEquals(overlap2Checkin, diaryStorage.getEntries().get(3).getCheckOutTime()); + assertEquals(overlap2Checkout, diaryStorage.getEntries().get(4).getCheckInTime()); + assertEquals(checkinTime+ VenueInfoExtensionsKt.getAutoCheckoutDelay(checkinVenueInfo), diaryStorage.getEntries().get(4).getCheckOutTime()); + + } + +} diff --git a/app/src/androidTest/java/ch/admin/bag/dp3t/FakeWorkerTest.java b/app/src/androidTest/java/ch/admin/bag/dp3t/FakeWorkerTest.java index 661676f38..afd5a5beb 100644 --- a/app/src/androidTest/java/ch/admin/bag/dp3t/FakeWorkerTest.java +++ b/app/src/androidTest/java/ch/admin/bag/dp3t/FakeWorkerTest.java @@ -330,6 +330,10 @@ public long currentTimeMillis() { return System.currentTimeMillis() + clockOffset; } + public int getUserInteractionDelay() { + return 0; + } + } private long setTDummyToDaysFromNow(int daysFromNow) { diff --git a/app/src/dev/java/ch/admin/bag/dp3t/debug/DebugFragment.java b/app/src/dev/java/ch/admin/bag/dp3t/debug/DebugFragment.java index 378046470..6afd39244 100644 --- a/app/src/dev/java/ch/admin/bag/dp3t/debug/DebugFragment.java +++ b/app/src/dev/java/ch/admin/bag/dp3t/debug/DebugFragment.java @@ -26,7 +26,10 @@ import androidx.lifecycle.ViewModelProvider; import java.util.Arrays; +import java.util.TimeZone; +import org.crowdnotifier.android.sdk.model.ExposureEvent; +import org.crowdnotifier.android.sdk.storage.ExposureStorage; import org.dpppt.android.sdk.DP3T; import org.dpppt.android.sdk.internal.nearby.ExposureWindowMatchingWorker; import org.dpppt.android.sdk.internal.storage.ExposureDayStorage; @@ -34,9 +37,12 @@ import org.dpppt.android.sdk.models.ExposureDay; import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel; +import ch.admin.bag.dp3t.checkin.models.SwissCovidAssociatedData; import ch.admin.bag.dp3t.debug.model.DebugAppState; import ch.admin.bag.dp3t.networking.CertificatePinning; import ch.admin.bag.dp3t.storage.SecureStorage; +import ch.admin.bag.dp3t.util.NotificationUtil; import ch.admin.bag.dp3t.viewmodel.TracingViewModel; public class DebugFragment extends Fragment { @@ -44,6 +50,7 @@ public class DebugFragment extends Fragment { public static final boolean EXISTS = true; private TracingViewModel tracingViewModel; + private CrowdNotifierViewModel crowdNotifierViewModel; public static void startDebugFragment(FragmentManager parentFragmentManager) { parentFragmentManager.beginTransaction() @@ -65,6 +72,7 @@ public DebugFragment() { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); tracingViewModel = new ViewModelProvider(requireActivity()).get(TracingViewModel.class); + crowdNotifierViewModel = new ViewModelProvider(requireActivity()).get(CrowdNotifierViewModel.class); } @Override @@ -100,7 +108,18 @@ private void setupSdkViews(View view) { }); view.findViewById(R.id.debug_button_reset_update_boarding).setOnClickListener(v -> { - SecureStorage.getInstance(requireContext()).setLastShownUpdateBoardingVersion(0); + SecureStorage secureStorage = SecureStorage.getInstance(requireContext()); + secureStorage.setLastShownUpdateBoardingVersion(0); + secureStorage.setCheckInUpdateNotificationShown(false); + getActivity().finish(); + }); + + view.findViewById(R.id.debug_button_sync_checkin_keys).setOnClickListener(v -> crowdNotifierViewModel.refreshTraceKeys()); + + view.findViewById(R.id.debug_button_simulate_from_instant_app).setOnClickListener(v -> { + setDebugAppState(DebugAppState.NONE); + tracingViewModel.resetSdk(); + SecureStorage.getInstance(requireContext()).setOnlyPartialOnboardingCompleted(true); getActivity().finish(); }); @@ -137,23 +156,30 @@ private void setupStateOptions(View view) { updateRadioGroup(optionsGroup); view.findViewById(R.id.debug_button_testmeldung).setOnClickListener(v -> { - showExposureDaysInputDialog(); + showExposureDaysInputDialogs(); }); } - private void showExposureDaysInputDialog() { + + private void showInputDialog(String title, String defaultInput, InputDialogCallback callback) { AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setTitle(getString(R.string.number_of_exposure_days)); + builder.setTitle(title); final EditText input = new EditText(getContext()); - input.setText("2"); + input.setText(defaultInput); input.setInputType(InputType.TYPE_NUMBER_FLAG_SIGNED | InputType.TYPE_CLASS_NUMBER); builder.setView(input); builder.setPositiveButton(R.string.android_button_ok, - (a, b) -> exposeMyself(Integer.parseInt(input.getText().toString()))); + (a, b) -> callback.onResult(Integer.parseInt(input.getText().toString()))); builder.show(); } - private void exposeMyself(int numberOfDays) { + private void showExposureDaysInputDialogs() { + showInputDialog(getString(R.string.number_of_exposure_days), "2", tracingExposures -> + showInputDialog("How many Checkin Exposures should be simulated?", "2", checkinExposures -> + exposeMyself(tracingExposures, checkinExposures))); + } + + private void exposeMyself(int numberOfDays, int numberOfCheckins) { ExposureDayStorage eds = ExposureDayStorage.getInstance(requireContext()); eds.clear(); @@ -163,6 +189,21 @@ private void exposeMyself(int numberOfDays) { ExposureDay exposureDay = new ExposureDay(i, dayOfExposure, System.currentTimeMillis()); eds.addExposureDays(requireContext(), Arrays.asList(exposureDay)); } + + ExposureStorage exposureStorage = ExposureStorage.getInstance(requireContext()); + exposureStorage.clear(); + for (int i = 0; i < numberOfCheckins; i++) { + long exposureStart = new DayDate().subtractDays(i).getStartOfDay(TimeZone.getDefault()); + long exposureEnd = exposureStart + 1000L * 60 * 60; + exposureStorage.addEntry(new ExposureEvent(-i, exposureStart, exposureEnd, "debug message", + SwissCovidAssociatedData.getDefaultInstance().toByteArray())); + } + if (numberOfCheckins > 0) { + SecureStorage secureStorage = SecureStorage.getInstance(requireContext()); + NotificationUtil.generateContactNotification(requireContext()); + secureStorage.setAppOpenAfterNotificationPending(true); + secureStorage.setReportsHeaderAnimationPending(true); + } getActivity().finish(); } @@ -193,4 +234,9 @@ public void setDebugAppState(DebugAppState debugAppState) { ((TracingStatusWrapper) tracingViewModel.getTracingStatusInterface()).setDebugAppState(getContext(), debugAppState); } + private interface InputDialogCallback { + void onResult(int selectedNumber); + + } + } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1a0acd78f..322e73cd1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,12 @@ + + + + + + + + + + + + + + + + @@ -72,6 +94,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/disclaimer/de/data_protection_statement.html b/app/src/main/assets/disclaimer/de/data_protection_statement.html index 35a21b3b2..85e393105 100644 --- a/app/src/main/assets/disclaimer/de/data_protection_statement.html +++ b/app/src/main/assets/disclaimer/de/data_protection_statement.html @@ -11,13 +11,14 @@ Datenschutzerklärung des Bundesamtes für Gesundheit BAG im Zusammenhang mit der Nutzung der «SwissCovid-App»

- Version: 9. April 2021 + Version: 1. Juli 2021

In dieser Datenschutzerklärung erläutert das Bundesamt für Gesundheit (BAG), inwieweit es hinsichtlich der Nutzung der Applikation «SwissCovid-App» (nachfolgend App) in der Schweiz Personendaten bearbeitet. Das ist keine abschliessende Beschreibung; allenfalls regeln andere Datenschutzerklärungen, ähnliche Dokumente, Nutzungsbedingungen oder Anwendungsprogramme spezifische Sachverhalte.

- Das Datenschutzrecht regelt die Bearbeitung von Personendaten. Die Bundesgesetzgebung über den Datenschutz ist auf die Datenbearbeitung anwendbar. Die Datenschutzerklärung steht zudem im Einklang mit dem Epidemiengesetz vom 28. September 2012 (EpG; SR 818.101) und der Verordnung vom 24. Juni 2020 über das Proximity-Tracing-System für das Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25). + Das Datenschutzrecht regelt die Bearbeitung von Personendaten. Die Bundesgesetzgebung über den Datenschutz ist auf die Bearbeitung von Personendaten anwendbar. Die Datenschutzerklärung steht zudem im Einklang mit dem Epidemiengesetz vom 28. September 2012 (EpG; SR 818.101), der Verordnung vom 24. Juni 2020 über das Proximity-Tracing-System für das Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25), + dem Bundesgesetz über die gesetzlichen Grundlagen für Verordnungen des Bundesrates zur Bewältigung der Covid-19-Epidemie vom 25. September 2020 (Covid-19-Gesetz; SR 818.102) sowie der Verordnung über ein Warnsystem zu Covid-19 für Veranstaltungen vom 1. Juli 2021.

Unter Personendaten werden alle Angaben verstanden, die sich auf eine bestimmte oder bestimmbare Person beziehen. Bearbeiten meint jeden Umgang mit Personendaten, unabhängig von den angewandten Mitteln und Verfahren, insbesondere das Beschaffen, Aufbewahren, Verwenden, Umarbeiten, Bekanntgeben, Archivieren oder Vernichten der Daten. @@ -48,23 +49,27 @@

Erhebung und Bearbeitung von Personendaten

- Das gesamte App-System ist darauf ausgerichtet, dass der Benutzer und die Benutzerin der App nicht bestimmbar sind. Die Bearbeitung von Personendaten wird minimiert. So sind keinerlei technische Rückschlüsse auf Personen, Standorte oder Geräte möglich. Ortsangaben werden keine erfasst, sondern lediglich verschlüsselte Daten betreffend die Kontakt-Ereignisse. Diese werden technisch vor Missbrauch geschützt. Das BAG kann keine Rückschlüsse auf die Benutzerinnen und Benutzer der App ziehen. Die App schützt die Daten der Benutzerinnen und Benutzer so, dass über weite Strecken kein Personenbezug hergestellt werden kann. Dennoch ist ein Personenbezug nicht absolut auszuschliessen. So besteht eine gewisse Wahrscheinlichkeit, dass bei der Benachrichtigung + Das gesamte App-System ist darauf ausgerichtet, dass der Benutzer und die Benutzerin der App nicht bestimmbar sind. Die Bearbeitung von Personendaten wird minimiert. So sind keinerlei technische Rückschlüsse auf Personen, Standorte oder Geräte möglich. Ortsangaben werden bei den Begegnungen via Proximity-Tracing keine erfasst, sondern lediglich verschlüsselte Daten betreffend die Kontakt-Ereignisse. Beim Warnsystem werden nur verschlüsselte Daten über Veranstaltungen und über Ansteckungsgefahren an diesen Veranstaltungen erfasst. Diese Daten werden technisch vor Missbrauch geschützt. Das BAG kann keine Rückschlüsse auf die Benutzerinnen und Benutzer der App ziehen und die Daten nicht entschlüsseln. Die App schützt die Daten der Benutzerinnen und Benutzer so, dass über weite Strecken kein Personenbezug hergestellt werden kann. Dennoch ist ein Personenbezug nicht absolut auszuschliessen. So besteht insb. beim Proximity-Tracing-System eine gewisse Wahrscheinlichkeit, dass bei der Benachrichtigung - einer möglicherweise gefährdeten Person, diese aufgrund ihrer Erinnerung an ihre Sozialkontakte der letzten Tage allenfalls Rückschlüsse auf die Identität der infizierten Person ziehen kann. Durch die Benutzung der App können somit potenziell auch Personen identifiziert werden. Die Benachrichtigung umfasst weiter Verhaltensempfehlungen des BAG, die Information, dass die Benutzerin oder der Benutzer potenziell dem Coronavirus ausgesetzt war, die Angabe, an welchen Tagen dies der Fall war sowie den Hinweis, dass das BAG einen Leitfaden (Web-Formular) und eine Infoline zur kostenlosen Beratung betreibt. Verlässt die Benutzerin oder der Benutzer die App, um den Leitfaden auszufüllen, werden die in der App genannten Tage, an welchen die Möglichkeit einer Ansteckung bestand, automatisch an den Leitfaden übermittelt. + einer möglicherweise gefährdeten Person diese aufgrund ihrer Erinnerung an ihre Sozialkontakte der letzten Tage allenfalls Rückschlüsse auf die Identität der infizierten Person ziehen kann. Durch die Benutzung der App können somit potenziell auch Personen identifiziert werden. Die Benachrichtigung aufgrund eines engen Kontaktes mit einer positiv getesteten Nutzerin / einem positiv getesteten Nutzer umfasst weiter Verhaltensempfehlungen des BAG, die Information, dass die Benutzerin oder der Benutzer potenziell dem Coronavirus ausgesetzt war, die Angabe, an welchen Tagen dies der Fall war sowie den Hinweis, dass das BAG einen Leitfaden (Web-Formular) und eine Infoline zur kostenlosen Beratung betreibt. Verlässt die Benutzerin oder der Benutzer die App, um den Leitfaden auszufüllen, werden die in der App genannten Tage, an welchen die Möglichkeit einer Ansteckung bestand, automatisch an den Leitfaden übermittelt. Die Benachrichtigung aufgrund der Check-in Funktion umfasst die obengenannten Punkte, jedoch wird hier kein Leitfaden oder eine Infoline angeboten; der primäre Inhalt der Benachrichtigung ist es, sich testen zu lassen.

  • - Das App-System gliedert sich in zwei Komponenten: + Das App-System gliedert sich in drei Komponenten:
  • - ein System zur Verwaltung der Annäherungsdaten, bestehend aus einer Software, die von den Benutzerinnen und Benutzern auf ihren Mobiltelefonen installiert wird, und einem Backend (VA-Backend); + ein System zur Verwaltung der Annäherungsdaten, + bestehend aus einer Software, die von den Benutzerinnen und Benutzern auf ihren Mobiltelefonen installiert wird, und einem Backend (VA-Backend);
  • ein System zur Verwaltung von Codes zur Freischaltung der Benachrichtigungen (Codeverwaltungssystem), bestehend aus einem webbasierten Frontend und einem Backend.
  • - Die beiden Backends unterstehen als zentrale Server direkt der Kontrolle des BAG und werden technisch vom Bundesamt für Informatik und Kommunikation (BIT) betrieben. Die Codeverwaltungs-Frontends laufen auf Geräten der für die Generierung des Freischaltcodes («Covidcodes») berechtigten Fachpersonen. + ein System zur Verwaltung der Veranstaltungen, bestehend aus einer Software, die von den Benutzerinnen und Benutzern auf ihren Mobiltelefonen installiert wird, und einem Backend (Veranstaltungs-Backend). +
  • +
  • + Die drei Backends unterstehen als zentrale Server direkt der Kontrolle des BAG und werden technisch vom Bundesamt für Informatik und Kommunikation (BIT) betrieben. Die Codeverwaltungs-Frontends laufen auf Geräten der für die Generierung des Freischaltcodes («Covidcodes») berechtigten Fachpersonen.

@@ -78,7 +83,10 @@

die Signalstärke;
  • - das Datum und die geschätzte Dauer der Annäherung. + das Datum und die geschätzte Dauer der Annäherung; +
  • +
  • + die Veranstaltungs-Identifizierungscodes mit dem jeweiligen Datum, Dauer des Aufenthaltes und Bezeichnung der Veranstaltung.
  • @@ -107,7 +115,21 @@

    - Darüber hinaus kann das App-System mit entsprechenden ausländischen Systemen verbunden werden, wenn im betreffenden Staat ein angemessener Schutz der Persönlichkeit gewährleistet wird (durch die Gesetzgebung oder hinreichende Garantien, insbesondere durch Vertrag). Ausländische Systeme gelten dann als «entsprechend», wenn sie nach den folgenden Grundsätzen des App-Systems ausgestaltet sind: + Das Veranstaltungs-Backend besteht aus einer Liste mit folgenden Daten: +

    +
      +
    • + die Veranstaltungs-Identifizierungscodes der infizierten Teilnehmenden, die in dem Zeitraum aktuell waren, in dem eine Ansteckung von anderen Personen an einer Veranstaltung möglich war; +
    • +
    • + dem Datum jedes Veranstaltungs-Identifizierungscodes; +
    • +
    • + dem verschlüsselten relevanten Zeitraum des infizierten Teilnehmenden pro Veranstaltungs-Identifizierungscode. +
    • +
    +

    + Darüber hinaus kann das Proximity-Tracing-System mit entsprechenden ausländischen Systemen verbunden werden, wenn im betreffenden Staat ein angemessener Schutz der Persönlichkeit gewährleistet wird (durch die Gesetzgebung oder hinreichende Garantien, insbesondere durch Vertrag). Ausländische Systeme gelten dann als «entsprechend», wenn sie nach den folgenden Grundsätzen des App-Systems ausgestaltet sind:

    • @@ -132,8 +154,8 @@

      Zwecke und Rechtsgrundlagen

      - Das vom BAG betriebene App-System und das Verbindungssystem stützt sich auf das EpG und die VPTS. - Die App und die mit ihr einhergehenden Datenbearbeitungen bezwecken ausschliesslich, die Benutzerinnen und Benutzer, die potenziell dem Coronavirus ausgesetzt waren, unter Wahrung des Datenschutzes zu benachrichtigen sowie Statistiken aus Daten der beiden Backends im Zusammenhang mit dem Coronavirus zu erstellen. + Das vom BAG betriebene App-System und das Verbindungssystem stützt sich auf das EpG, die VPTS, das Covid-19-Gesetz sowie die VWV. + Die App und die mit ihr einhergehenden Datenbearbeitungen bezwecken ausschliesslich, die Benutzerinnen und Benutzer, die potenziell dem Coronavirus ausgesetzt waren, unter Wahrung des Datenschutzes zu benachrichtigen sowie Statistiken aus Daten der drei Backends im Zusammenhang mit dem Coronavirus zu erstellen.

      Das Verbindungssystem dient dazu, dass eine solche Benachrichtigung auch zwischen verbundenen nationalen Apps möglich ist. Damit können Benutzerinnen und Benutzer der App auch dann benachrichtigt werden, wenn sie eine Annäherung zu infizierten Benutzerinnen und Benutzern einer ausländischen App gehabt haben (z.B. zu Grenzgängern und Touristen im Inland oder Kontakten im Ausland). Umgekehrt können auch Benutzerinnen und Benutzer einer verbundenen ausländischen App benachrichtigt werden bei Annäherungen zu infizierten Benutzerinnen und Benutzern der App. @@ -144,20 +166,20 @@

      Datenweitergabe

      - Die Liste mit den Daten des VA-Backends wird der App im Abrufverfahren zur Verfügung gestellt. Soweit das BAG für diesen Service Dritte im In- oder Ausland beauftragt, verpflichten sich diese vertraglich, die Vorgaben nach Artikel 60a EpG und der VPTS einzuhalten. Davon ausgenommen ist die Regelung betreffend den Quellcode nach Artikel 60 + Die Liste mit den Daten des VA-Backends sowie des Veranstaltungs-Backends wird der App im Abrufverfahren zur Verfügung gestellt. Soweit das BAG für diesen Service Dritte im In- oder Ausland beauftragt, verpflichten sich diese vertraglich, die Vorgaben nach Artikel 60a EpG, der VPTS, Artikel 3 Absatz 7 Buchstabe a Covid-19-Gesetz und der VWV einzuhalten. Davon ausgenommen ist die Regelung betreffend den Quellcode nach Artikel 60 a - Absatz 5 Buchstabe e EpG. Das BAG kontrolliert die Einhaltung der Vorgaben. Bei der Ausführung des Auftrags anfallende Randdaten dürfen die beauftragten Dritten nicht für eigene Zwecke nutzen. Diese Daten werden nur vom BAG bzw. vom BIT ausgewertet (vgl. Ziffer 8). + Absatz 5 Buchstabe e EpG und Artikel 15 VWV. Das BAG kontrolliert die Einhaltung der Vorgaben. Bei der Ausführung des Auftrags anfallende Randdaten dürfen die beauftragten Dritten nicht für eigene Zwecke nutzen. Diese Daten werden nur vom BAG bzw. vom BIT ausgewertet (vgl. Ziffer 8).

      Die Liste mit den privaten Schlüsseln der infizierten Benutzerinnen und Benutzer des VA-Backends wird zudem regelmässig für eine grenzüberschreitende Benachrichtigung an das Verbindungssystem übermittelt. Sodann laden die verbundenen ausländischen Systeme (zurzeit: die deutsche Corona-Warn-App) diese privaten Schlüssel herunter und stellen sie ihren Apps zum Abruf zur Verfügung (siehe Ziffer 2).

      - Das BAG stellt dem Bundesamt für Statistik (BFS) periodisch den aktuellen Bestand der in beiden Backends vorhandenen Daten in anonymisierter Form für statistische Auswertungen zur Verfügung. Die Daten des Verbindungssystems können dem BFS und der zuständigen ausländischen Stelle zu Statistikzwecken in vollständig anonymisierter Form bekanntgegeben werden. Das BIT führt im Auftrag des BAG den Betrieb der gesamten Software durch und stellt den notwendigen technischen Support-Service bereit. Das BIT hat lediglich Zugriff auf Daten, soweit dies für die beschriebenen Zwecke und die Tätigkeit der betreffenden Mitarbeiter erforderlich ist. Diese sind im Umgang mit den Daten zu Vertraulichkeit verpflichtet. + Das BAG stellt dem Bundesamt für Statistik (BFS) periodisch den aktuellen Bestand der in den drei Backends vorhandenen Daten in anonymisierter Form für statistische Auswertungen zur Verfügung. Die Daten des Verbindungssystems können dem BFS und der zuständigen ausländischen Stelle zu Statistikzwecken in vollständig anonymisierter Form bekanntgegeben werden. Das BIT führt im Auftrag des BAG den Betrieb der gesamten Software durch und stellt den notwendigen technischen Support-Service bereit. Das BIT hat lediglich Zugriff auf Daten, soweit dies für die beschriebenen Zwecke und die Tätigkeit der betreffenden Mitarbeiter erforderlich ist. Diese sind im Umgang mit den Daten zu Vertraulichkeit verpflichtet.

      - Die App verwendet eine Schnittstelle zum Betriebssystem des Mobiltelefons der Benutzerin oder des Benutzers, welche die Bearbeitung von Daten durch Apple oder Google bedingt. Die über die Schnittstelle genutzten Funktionen der Betriebssysteme müssen die Vorgaben von Artikel 60a EpG und der VPTS erfüllen. Davon ausgenommen ist die Regelung betreffend den Quellcode nach Artikel 60 + Die App verwendet für das Proximity-Tracing-System eine Schnittstelle zum Betriebssystem des Mobiltelefons der Benutzerin oder des Benutzers, welche die Bearbeitung von Daten durch Apple oder Google bedingt. Die über die Schnittstelle genutzten Funktionen der Betriebssysteme müssen die Vorgaben von Artikel 60a EpG und der VPTS erfüllen. Davon ausgenommen ist die Regelung betreffend den Quellcode nach Artikel 60 a @@ -173,6 +195,9 @@

    • die Daten des Systems zur Verwaltung der Annäherungsdaten (sowohl auf den Mobiltelefonen als auch im VA-Backend): 14 Tage nach ihrer Erfassung;
    • +
    • + die Daten des Systems zur Verwaltung der Veranstaltungen (sowohl auf den Mobiltelefonen als auch im Veranstaltungs-Backend): 14 Tage nach ihrer Erfassung; +
    • die Daten des Codeverwaltungssystems: 24 Stunden nach ihrer Erfassung;
    • @@ -202,7 +227,7 @@

      Weiteres

      - Es werden Protokolle über Zugriffe auf das VA-Backend und das Codeverwaltungssystem zu den in den Artikeln 57 + Es werden Protokolle über Zugriffe auf das VA-Backend, das Veranstaltungs-Backend und das Codeverwaltungssystem zu den in den Artikeln 57 l diff --git a/app/src/main/assets/disclaimer/de/terms_of_use.html b/app/src/main/assets/disclaimer/de/terms_of_use.html index 2151227e3..5b7da35b3 100644 --- a/app/src/main/assets/disclaimer/de/terms_of_use.html +++ b/app/src/main/assets/disclaimer/de/terms_of_use.html @@ -7,7 +7,6 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> - 1. Geltungsbereich und Zweck @@ -18,7 +17,7 @@

      1.2 - Die App des Bundesamtes für Gesundheit (BAG) stützt sich auf das Epidemiengesetz vom 28. September 2012 (EpG; SR 818.101) und die Verordnung vom 24. Juni 2020 über das Proximity-Tracing-System für das Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25). + Die App des Bundesamtes für Gesundheit (BAG) stützt sich auf das Epidemiengesetz vom 28. September 2012 (EpG; SR 818.101), die Verordnung vom 24. Juni 2020 über das Proximity-Tracing-System für das Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25), dem Bundesgesetz über die gesetzlichen Grundlagen für Verordnungen des Bundesrates zur Bewältigung der Covid-19-Epidemie vom 25. September 2020 (Covid-19-Gesetz; SR 818.102) sowie der Verordnung über ein Warnsystem zu Covid-19 für Veranstaltungen vom 1. Juli 2021.

      1.3 @@ -34,7 +33,7 @@

      2.2 - Die Nutzung der App ist nicht auf ein geografisches Gebiet beschränkt. Allerdings können Warnungen nur erfolgen, wenn die ausländische App mit der SwissCovid-App interoperabel ist. Zum gegenwärtigen Zeitpunkt ist lediglich die in Deutschland genutzte Corona-Warn-App interoperabel. + Die Nutzung der App ist nicht auf ein geografisches Gebiet beschränkt. Allerdings können Warnungen nur erfolgen, wenn die ausländische App mit der SwissCovid-App interoperabel ist. Zum gegenwärtigen Zeitpunkt ist lediglich die in Deutschland genutzte Corona-Warn-App mit dem Proximity-Tracing-System der SwissCovid-App interoperabel.

      2.3 @@ -46,18 +45,18 @@

      3.1 - Mittels der App werden Benutzerinnen und Benutzer informiert, wenn sie mit mindestens einer nachweislich infizierten Benutzerin oder einem nachweislich infizierten Benutzer der SwissCovid-App oder anderen interoperablen Apps in relevantem Kontakt waren. + Mittels der App werden Benutzerinnen und Benutzer informiert, wenn sie mit mindestens einer nachweislich infizierten Benutzerin oder einem nachweislich infizierten Benutzer der SwissCovid-App oder anderen interoperablen Apps in relevantem Kontakt waren. Dieser relevante Kontakt kann aufgrund des Proximity-Tracings oder aufgrund der Teilnahme an einer Veranstaltung eruiert werden.

      3.2 - Voraussetzung für das Funktionieren der App ist die Aktivierung von Bluetooth. + Voraussetzung für das Proximity-Tracing-System der App ist die Aktivierung von Bluetooth. Für das Warnsystem braucht es hingegen kein Bluetooth.

      3.3 - Die App erfüllt unter Verwendung einer Schnittstelle zum Betriebssystem des Mobiltelefons der Benutzerin oder des Benutzers folgende Funktionen: + Die App erfüllt – beim Proximity-Tracing-System unter Verwendung einer Schnittstelle zum Betriebssystem des Mobiltelefons der Benutzerin oder des Benutzers – folgende Funktionen:

      - Das Betriebssystem generiert jeden Tag einen neuen privaten Schlüssel, der keine Rückschlüsse auf die App, das Mobiltelefon und die Benutzerinnen und Benutzer ermöglicht. Innerhalb der Reichweite von Bluetooth tauscht das Betriebssystem mit allen kompatiblen und aktiven Apps einen mindestens halbstündlich wechselnden Identifizierungscode (sog. «random ID») aus, der aus einem aktuellen privaten Schlüssel abgeleitet wird, aber nicht auf diesen Schlüssel zurückgeführt werden kann und ebenfalls keine Rückschlüsse auf die App, das Mobiltelefon und deren Benutzerinnen und Benutzer ermöglicht. + Proximity-Tracing-System: Das Betriebssystem generiert jeden Tag einen neuen privaten Schlüssel, der keine Rückschlüsse auf die App, das Mobiltelefon und die Benutzerinnen und Benutzer ermöglicht. Innerhalb der Reichweite von Bluetooth tauscht das Betriebssystem mit allen kompatiblen und aktiven Apps einen mindestens halbstündlich wechselnden Identifizierungscode (sog. «random ID») aus, der aus einem aktuellen privaten Schlüssel abgeleitet wird, aber nicht auf diesen Schlüssel zurückgeführt werden kann und ebenfalls keine Rückschlüsse auf die App, das Mobiltelefon und deren Benutzerinnen und Benutzer ermöglicht.

      Das Betriebssystem speichert auf dem Mobiltelefon die empfangenen Identifizierungscodes, die Signalstärke, das Datum und die geschätzte Dauer der Annäherung. @@ -65,21 +64,24 @@

      Die App ruft periodisch eine Liste der privaten Schlüssel der infizierten Benutzerinnen und Benutzer der SwissCovid-App und anderen interoperablen Apps ab und lässt vom Betriebssystem überprüfen, ob mindestens ein lokal gespeicherter Identifizierungscode mit einem privaten Schlüssel der Liste generiert wurde. Ist dies der Fall und bestand zu mindestens einem Mobiltelefon einer infizierten Benutzerin oder einem infizierten Benutzer der SwissCovid-App oder anderen interoperablen Apps eine Annährung von 1,5 Metern oder weniger und erreicht die Summe der Dauer aller solchen Annäherungen innerhalb eines Tages 15 Minuten, so gibt die App die Benachrichtigung aus. Der Abstand wird anhand der Stärke der empfangenen Signale geschätzt.

      +

      + Warnsystem für Veranstaltungen: Die Organisatorin oder der Organisator einer Veranstaltung generiert in seiner App einen QR-Code. Besucherinnen und Besucher der Veranstaltung können mit ihrer App den gezeigten QR-Code scannen und sind so an der Veranstaltung eingecheckt. +

      3.4 - Ist eine Infektion bei einer Benutzerin oder einem Benutzer nachgewiesen, generieren zugriffsberechtigte Fachpersonen (z.B. behandelnde Ärztinnen und Ärzte oder Apothekerinnen und Apotheker) einen einmaligen und zeitlich begrenzt gültigen Freischaltcode («Covidcode») und geben diesen der infizierten Benutzerin oder dem infizierten Benutzer bekannt. Diese oder dieser kann den Freischaltcode in ihre oder seine App freiwillig eingeben. Die Benachrichtigung bzw. die Eingabe des Freischaltcodes erfolgt nur mit der ausdrücklichen Einwilligung der infizierten Benutzerin oder des infizierten Benutzers. + Die App speichert auf dem Mobiltelefon die Veranstaltungs-Identifizierungscodes mit dem jeweiligen Datum, die Dauer des Aufenthaltes und die Bezeichnung der Veranstaltung. Sie ruft periodisch vom Veranstaltungs-Backend im Abrufverfahren die Liste der Veranstaltungs-Identifizierungscodes der infizierten teilnehmenden Personen ab. Sie gleicht die Veranstaltungs-Identifizierungscodes mit den von ihr lokal gespeicherten Veranstaltungs-Identifizierungscodes ab. Ergibt der Abgleich eine Übereinstimmung, so gibt die App die Benachrichtigung aus.Ist eine Infektion bei einer Benutzerin oder einem Benutzer nachgewiesen, generieren zugriffsberechtigte Fachpersonen (z.B. behandelnde Ärztinnen und Ärzte oder Apothekerinnen und Apotheker) einen einmaligen und zeitlich begrenzt gültigen Freischaltcode («Covidcode») und geben diesen der infizierten Benutzerin oder dem infizierten Benutzer bekannt. Diese oder dieser kann den Freischaltcode in ihre oder seine App freiwillig eingeben. Die Benachrichtigung bzw. die Eingabe des Freischaltcodes erfolgt nur mit der ausdrücklichen Einwilligung der infizierten Benutzerin oder des infizierten Benutzers. Die infizierte Benutzerin oder der infizierte Benutzer kann ausserdem wählen, ob sie oder er sowohl die engen Kontakte aufgrund des Proximity-Tracing-Systems als auch die Kontakte aufgrund der Teilnahme an einer Veranstaltung warnen will. Darüber hinaus kann sie oder er auch entscheiden, ob er für jede Veranstaltung, an welcher er im relevanten Zeitraum teilgenommen hat, eine Benachrichtigung auslösen will oder nur für einzelne.

      Die anderen Benutzerinnen und Benutzer der SwissCovid-App
      - oder anderen interoperablen Apps, welche in der infektiösen Zeitspanne eine Annährung gemäss Ziffer 3.3 zu der infizierten Benutzerin oder dem infizierten Benutzer hatten, werden durch die eigenen Apps benachrichtigt. + oder anderer interoperabler Apps, welche in der infektiösen Zeitspanne eine Annährung gemäss Ziffer 3.3 zu der infizierten Benutzerin oder dem infizierten Benutzer hatten oder gleichzeitig an derselben Veranstaltung teilgenommen haben, werden durch die eigenen Apps benachrichtigt.

      - Die benachrichtigten Benutzerinnen und Benutzer erfahren, dass eine Annährung stattgefunden hat bzw. dass sie potenziell dem Coronavirus ausgesetzt waren und an welchen Tagen dies der Fall war. Sie erfahren nicht, welcher Benutzer oder welche Benutzerin infiziert ist und die Benachrichtigung ausgelöst hat. Die Benachrichtigung der SwissCovid-App umfasst weiter Verhaltensempfehlungen des BAG sowie den Hinweis, dass das BAG einen Leitfaden (Web-Formular) und eine Infoline zur kostenlosen Beratung betreibt. Verlässt man die App, um den Leitfaden auszufüllen, werden die in der App genannten Tage, an welchen eine Möglichkeit der Ansteckung bestand, automatisch an den Leitfaden übermittelt. + Die benachrichtigten Benutzerinnen und Benutzer erfahren, dass eine Annährung stattgefunden hat bzw. dass sie potenziell dem Coronavirus ausgesetzt waren und an welchen Tagen dies der Fall war. Sie erfahren nicht, welcher Benutzer oder welche Benutzerin infiziert ist und die Benachrichtigung ausgelöst hat. Die Benachrichtigung der SwissCovid-App aufgrund eines engen Kontaktes mit einer positiv getesteten Nutzerin / einem positiv getesteten Nutzer umfasst weiter Verhaltensempfehlungen des BAG sowie den Hinweis, dass das BAG einen Leitfaden (Web-Formular) und eine Infoline zur kostenlosen Beratung betreibt. Verlässt man die App, um den Leitfaden auszufüllen, werden die in der App genannten Tage, an welchen eine Möglichkeit der Ansteckung bestand, automatisch an den Leitfaden übermittelt. Die Benachrichtigung aufgrund des Warnsystems umfasst die obengenannten Punkte, jedoch wird hier kein Leitfaden und keine Infoline angeboten, die primäre Empfehlung ist es, sich testen zu lassen.

      3.5 - Bei Eingabe des Freischaltcodes werden die Benutzerinnen und Benutzer darauf aufmerksam gemacht, mit welchen Ländern (vgl. Ziffer 2.2) die App interoperabel ist. Es wird darauf hingewiesen, dass – wenn das Feld «Einverstanden» gedrückt und der Freischaltcode in die App eingegeben wird – die privaten Schlüssel nicht nur innerhalb der SwissCovid-App geteilt werden, sondern auch mit allen anderen interoperablen Apps (zurzeit die deutsche Corona-Warn-App). Die privaten Schlüssel können nur mit allen interoperablen Apps oder gar nicht geteilt werden, was dem sogenannten «One-World-Prinzip» entspricht. + Bei Eingabe des Freischaltcodes werden die Benutzerinnen und Benutzer darauf aufmerksam gemacht, mit welchen Ländern (vgl. Ziffer 2.2) die App interoperabel ist. Es wird darauf hingewiesen, dass – wenn das Feld «Einverstanden» gedrückt und der Freischaltcode in die App eingegeben wird – die privaten Schlüssel des Proximity-Tracing-Systems nicht nur innerhalb der SwissCovid-App geteilt werden, sondern auch mit allen anderen interoperablen Apps (zurzeit die deutsche Corona-Warn-App). Die privaten Schlüssel können nur mit allen interoperablen Apps oder gar nicht geteilt werden, was dem sogenannten «One-World-Prinzip» entspricht.

      3.6 @@ -186,7 +188,7 @@

      7.3 - Spätestens beim Ausserkrafttreten der in Ziffer 1.2. aufgeführten Verordnung deaktiviert das BAG die App und fordert die Benutzerinnen und Benutzer auf, diese auf dem Mobiltelefon zu deinstallieren. + Spätestens beim Ausserkrafttreten der in Ziffer 1.2. aufgeführten Verordnungen deaktiviert das BAG die App und fordert die Benutzerinnen und Benutzer auf, diese auf dem Mobiltelefon zu deinstallieren.

      8. diff --git a/app/src/main/assets/disclaimer/en/data_protection_statement.html b/app/src/main/assets/disclaimer/en/data_protection_statement.html index d786e9cda..57c7db91e 100644 --- a/app/src/main/assets/disclaimer/en/data_protection_statement.html +++ b/app/src/main/assets/disclaimer/en/data_protection_statement.html @@ -7,18 +7,18 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> -

      Data Protection Statement of the Federal Office of Public Health FOPH in connection with the use of the “SwissCovid app”

      - Version: 9 April 2021 + Version: 1 July 2021

      In this Data Protection Statement, the Federal Office of Public Health (FOPH) explains to what extent it will process personal data in connection with the use of the application “SwissCovid app” (hereafter app) in Switzerland. This account is not exhaustive; specific matters may be governed by other data protection statements, similar documents, terms and conditions of use, or applications.

      - The processing of personal data is governed by data protection legislation. The federal legislation on data protection is applicable to the data processing. In addition, the Data Protection Statement is in line with the Epidemics Act of 28 September 2012 (EpG; SR 818.101) and the Ordinance of 24 June 2020 on the Proximity Tracing System for the Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25). + The processing of personal data is governed by data protection legislation. The federal legislation on data protection is applicable to the processing of personal data. In addition, the Data Protection Statement is in line with the Epidemics Act of 28 September 2012 (EpG; CC 818.101), the Ordinance of 24 June 2020 on the Proximity Tracing System for the Coronavirus SARS-CoV-2 (VPTS; CC 818.101.25), + the Federal Act on the Statutory Principles for Federal Council Ordinances on Combating the COVID-19 Epidemic of 25 September 2020 (COVID-19 Act; CC 818.102) and the Ordinance on a COVID-19 Warning System for Events of 1 July 2021.

      “Personal data” means all information relating to an identified or identifiable person. “Processing” means any operation with personal data, irrespective of the means applied and the procedure, and in particular the collection, storage, use, revision, disclosure, archiving or destruction of data. @@ -44,20 +44,23 @@

      Collection and processing of personal data

      - The entire app system is designed to ensure that the app user is not identifiable. The processing of personal data is kept to a minimum. Data cannot be traced back by technical means to persons, locations or devices. What is collected is not location data, but merely encrypted data concerning proximity (contact) events. This is protected by technical means against misuse. The FOPH cannot draw any conclusions concerning app users. The app protects users’ data in such a way that it cannot, at a distance, be connected to specific persons. Connection to a specific person cannot, however, be ruled out altogether. There is a certain likelihood that, when someone is notified of a possible exposure, their recollection of social contacts over recent days may allow them to deduce the identity of the infected individual. As a result of using the app, persons may thus potentially be identified. The notification also contains the behavioural recommendations of the FOPH, the information that the user may potentially have been exposed to the coronavirus, the dates on which this was the case and it draws attention to a guide (web form) as well as the Infoline offering free advice operated by the FOPH. If the user leaves the app in order to complete the guide, the dates named in the app on which an infection may have taken place are automatically transferred to the guide. + The entire app system is designed to ensure that the app user is not identifiable. The processing of personal data is kept to a minimum. Data cannot be traced back by technical means to persons, locations or devices. What is collected via proximity tracing in the event of contacts is not location data, but merely encrypted data concerning proximity (contact) events. For the warning system only encrypted data about events and threats of infection at these events are collected. This data is protected by technical means against misuse. The FOPH cannot draw any conclusions concerning app users or decrypt the data. The app protects users’ data in such a way that it cannot, at a distance, be connected to specific persons. Connection to a specific person cannot, however, be ruled out altogether. In particular with the proximity tracing system, there is a certain likelihood that, when someone is notified of a possible exposure, their recollection of social contacts over recent days may allow them to deduce the identity of the infected individual. As a result of using the app, persons may thus potentially be identified. The notification on the basis of a close contact with a user who has tested negative also contains the behavioural recommendations of the FOPH, the information that the user may potentially have been exposed to the coronavirus, the dates on which this was the case and it draws attention to a guide (web form) as well as the Infoline offering free advice operated by the FOPH. If the user leaves the app in order to complete the guide, the dates named in the app on which an infection may have taken place are automatically transferred to the guide. Notifications on the basis of the check-in function cover the above-mentioned points, although here no guide or infoline is offered; the primary content of the notification is the recommendation to have a test.

      • - The app system has two components: + The app system has three components:
      • a proximity data management system, comprising software installed by users on their mobile phones and a back end (PM back end);
      • - a system for the management of codes for the activation of notifications (code management system), comprising a web-based front end and a back end. + a system for the management of codes for the activation of notifications (code management system), comprising a web-based front end and a back end;
      • - Both of the back ends, as central servers, are under the direct control of the FOPH and are operated technically by the Federal Office of Information Technology, Systems and Telecommunication (FOITT). The code management front ends run on the devices of the experts authorised to generate the activation code (Covid code). + a system for the management of events, comprising software installed by users on their mobile phones and a back end (event back end). +
      • +
      • + The three back ends, as central servers, are under the direct control of the FOPH and are operated technically by the Federal Office of Information Technology, Systems and Telecommunication (FOITT). The code management front ends run on the devices of the experts authorised to generate the activation code (Covid code).

      @@ -71,7 +74,10 @@

      the signal strength;

    • - the date and the estimated duration of proximity. + the date and the estimated duration of proximity; +
    • +
    • + the event identification codes with the relevant date, duration of attendance and designation of the event.

    @@ -100,7 +106,21 @@

    - The app system can also be connected to corresponding foreign systems if adequate protection of privacy is assured in the country in question (by means of corresponding legislation or sufficient guarantees, in particular in the form of an agreement). Foreign systems are considered to be “corresponding” if they are designed according to the following principles of the app system: + The event back end contains a list with the following data: +

    +
      +
    • + the event identification codes for the infected participants that were current at the time infection by other people at an event was possible; +
    • +
    • + the date of each event identification code; +
    • +
    • + the encrypted relevant period for the infected participant for each event identification code. +
    • +
    +

    + The proximity tracing system can also be connected to corresponding foreign systems if adequate protection of privacy is assured in the country in question (by means of corresponding legislation or sufficient guarantees, in particular in the form of an agreement). Foreign systems are considered to be “corresponding” if they are designed according to the following principles of the app system:

    • @@ -125,7 +145,7 @@

      Purposes and legal basis

      - The app system operated by the FOPH and the connection system are based on the EpG and the VPTS. The exclusive purposes of the app and the associated data processing are, in a privacy-preserving manner, to notify users potentially exposed to the coronavirus and to produce coronavirus-related statistics using data from the two back end systems. + The app system operated by the FOPH and the connection system are based on the EpG, the VPTS, the COVID-19 Act and the VWV. The exclusive purposes of the app and the associated data processing are, in a privacy-preserving manner, to notify users potentially exposed to the coronavirus and to produce coronavirus-related statistics using data from the three back end systems.

      The connection system serves to ensure that such a notification is also possible between connected national apps. This means that users of the app can also be notified if they have been in proximity to an infected user of a foreign app (e.g. cross-border commuters and tourists in Switzerland or contacts abroad). Conversely, users of a connected foreign app can also be notified if they have been in proximity to infected users of the app. @@ -136,15 +156,15 @@

      Data transfer

      - The PM back end data list is made available to the app (or front end) in the retrieval process. Insofar as the FOPH engages third parties in Switzerland or abroad to provide this service, they undertake contractually to comply with the requirements of Article 60 + The list of data in the PM and event back end systems is made available to the app (or front end) in the retrieval process. Insofar as the FOPH engages third parties in Switzerland or abroad to provide this service, they undertake contractually to comply with the requirements of Article 60 a - EpG and the VPTS, with the exception of the provisions concerning the source code specified in Article 60 + EpG, the VPTS, Article 3 paragraph 7 letter a COVID-19 Act and the VWV, with the exception of the provisions concerning the source code specified in Article 60 a - paragraph 5 letter e EpG. The FOPH monitors compliance with the legal requirements. The third parties engaged are not permitted to use non-core data arising in the execution of this task for their own purposes. This data will only be analysed by the FOPH or the FOITT (cf. Section 8). + paragraph 5 letter e EpG and Article 15 VWV. The FOPH monitors compliance with the legal requirements. The third parties engaged are not permitted to use non-core data arising in the execution of this task for their own purposes. This data will only be analysed by the FOPH or the FOITT (cf. Section 8).

      The list with the private keys of the infected users of the PM back end is also regularly transmitted to the connection system for a cross-border notification. The connected foreign systems (currently: the German Corona-Warn-App) then download these private keys and make them available to their apps for retrieval (see @@ -154,10 +174,10 @@

      ).

      - The FOPH will periodically make available to the Federal Statistical Office (FSO), in an anonymised form, the data currently held in the two back end systems, for purposes of statistical analysis. The data of the connection system can be disclosed to the FSO and the responsible foreign body for statistical purposes in a completely anonymised form. The FOITT operates the entire software on behalf of the FOPH and provides the necessary technical support service. The FOITT has access to data only insofar as this is necessary for the purposes described and the activities of the employees concerned. They are bound by confidentiality in the management of the data. + The FOPH will periodically make available to the Federal Statistical Office (FSO), in an anonymised form, the data currently held in the three back end systems, for purposes of statistical analysis. The data of the connection system can be disclosed to the FSO and the responsible foreign body for statistical purposes in a completely anonymised form. The FOITT operates the entire software on behalf of the FOPH and provides the necessary technical support service. The FOITT has access to data only insofar as this is necessary for the purposes described and the activities of the employees concerned. They are bound by confidentiality in the management of the data.

      - The app uses an interface to the operating system of the user’s mobile phone, which entails the processing of data by Apple or Google. The operating system functions used via the interface must comply with the requirements of Article 60 + For the proximity tracing system the app uses an interface to the operating system of the user’s mobile phone, which entails the processing of data by Apple or Google. The operating system functions used via the interface must comply with the requirements of Article 60 a @@ -177,6 +197,9 @@

    • data in the proximity data management system (both on mobile phones and in the PM back end): 14 days after capture;
    • +
    • + data in the event management system (both on mobile phones and in the event back end): 14 days after their collection; +
    • data in the code management system: 24 hours after capture;
    • @@ -206,7 +229,7 @@

      Other information

      - PM back end and code management system access events are logged for the purposes specified in Articles 57 + PM back end, event back end and code management system access events are logged for the purposes specified in Articles 57 l diff --git a/app/src/main/assets/disclaimer/en/terms_of_use.html b/app/src/main/assets/disclaimer/en/terms_of_use.html index 92aa2d2cf..f5bc6e64b 100644 --- a/app/src/main/assets/disclaimer/en/terms_of_use.html +++ b/app/src/main/assets/disclaimer/en/terms_of_use.html @@ -7,7 +7,6 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> - 1. Scope and purpose @@ -18,7 +17,7 @@

      1.2 - The Federal Office of Public Health (FOPH) app is based on the Epidemics Act of 28 September 2012 (EpG; SR 818.101) and the Ordinance of 24 June 2020 on the Proximity Tracing System for the Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25). + The Federal Office of Public Health (FOPH) app is based on the Epidemics Act of 28 September 2012 (EpG; SR 818.101) and the Ordinance of 24 June 2020 on the Proximity Tracing System for the Coronavirus SARS-CoV-2 (VPTS; SR 818.101.25), the Federal Act on the Statutory Principles for Federal Council Ordinances on Combating the COVID-19 Epidemic (COVID-19 Act; CC 818.102) and the Ordinance on a COVID-19 Warning System for Events of 24 June 2021.

      1.3 @@ -34,7 +33,7 @@

      2.2 - The use of the app is not restricted to a geographic region. However, warnings can only be issued if the foreign app is interoperable with the SwissCovid app. At present, only the Corona-Warn-App used in Germany is interoperable. + The use of the app is not restricted to a geographic region. However, warnings can only be issued if the foreign app is interoperable with the SwissCovid app. At present, only the Corona-Warn-App used in Germany is interoperable with the SwissCovid app’s proximity tracing system.

      2.3 @@ -46,18 +45,18 @@

      3.1 - Via the app, users are informed if they have been in relevant contact with at least one user of the SwissCovid app or other interoperable apps who has confirmed to have been infected. + Via the app, users are informed if they have been in relevant contact with at least one user of the SwissCovid app or other interoperable apps who has confirmed to have been infected. This relevant contact can be identified on the basis of proximity tracing or participation at an event.

      3.2 - The activation of Bluetooth is required for the operation of the app. + The activation of Bluetooth is required for the operation of the app’s proximity tracing system. Bluetooth is not required, however, for the warning system.

      3.3 - Using an interface to the operating system of the user’s mobile phone, the app fulfils the following functions: + The app – in the case of the proximity tracing system using an interface to the operating system of the user’s mobile phone – fulfils the following functions:

      - Each day, the operating system generates a new private key, which cannot be connected to the app, the mobile phone or users. Within the range of Bluetooth, the operating system exchanges with all compatible, running apps an identification code (random ID), changing at least every 30 minutes, which is derived from a current private key but cannot be traced back to this key and likewise cannot be connected to the app, the mobile phone or users. + Proximity tracing system: Each day, the operating system generates a new private key, which cannot be connected to the app, the mobile phone or users. Within the range of Bluetooth, the operating system exchanges with all compatible, running apps an identification code (random ID), changing at least every 30 minutes, which is derived from a current private key but cannot be traced back to this key and likewise cannot be connected to the app, the mobile phone or users.

      On the mobile phone, the operating system stores the identification codes received, the signal strength, the date and the estimated duration of proximity. @@ -65,19 +64,25 @@

      The app periodically retrieves a list of the private keys of users of the SwissCovid app and other interoperable apps known to be infected and allows the operating system to check whether at least one locally stored identification code was generated by a private key included in the list. If this is the case, and if proximity of 1.5 metres or less to the mobile phone of at least one infected user of the SwissCovid app or another interoperable app was registered, and if the duration of all such proximity events within one day amounts to at least 15 minutes, then the app issues a notification. Proximity is estimated on the basis of the strength of the signals received.

      +

      + Warning system for events: The organiser of an event generates a QR code in their app. People attending the event can use their app to scan the displayed QR code and are thus checked in to the event. +

      +

      + The app saves on the mobile phone the event identification codes with the relevant date, duration of attendance and designation of the event. It periodically retrieves from the event back end the list of event identification codes of the infected participants. It compares these event participation codes with the event participation codes stored locally by the app. If there is a match, the app issues the notification. +

      3.4 - If an infection is confirmed in a user, experts with access rights (e.g. attending physicians or pharmacists) generate a unique activation code (Covid code), valid for a limited period, which they disclose to the infected user. This user can voluntarily enter the activation code in the app. Notification, or entry of the activation code, occurs only with the explicit consent of the infected user. + If an infection is confirmed in a user, experts with access rights (e.g. attending physicians or pharmacists) generate a unique activation code (Covid code), valid for a limited period, which they disclose to the infected user. This user can voluntarily enter the activation code in the app. Notification, or entry of the activation code, occurs only with the explicit consent of the infected user. The infected user can also choose whether they want to warn close contacts on the basis of the proximity tracing system and contacts on the basis of participation in an event. In addition, they can decide whether they want to trigger a notification for every event they attended during the relevant period or only for individual events.

      - Other users of the SwissCovid app or other interoperable apps who came into proximity, as defined in Section 3.3, with the infected user during the infectious period are notified by their own apps. + Other users of the SwissCovid app or other interoperable apps who came into proximity, as defined in Section 3.3, with the infected user during the infectious period or participated in the same event at the same time are notified by their own apps.

      - The users thus notified learn that a proximity event has occurred, i.e. that they have potentially been exposed to the coronavirus, and on what dates this was the case. They are not told which user has been infected and has triggered the notification. The notification of the SwissCovid app also contains the behavioural recommendations of the FOPH and draws attention to a guide (web form) as well as the Infoline offering free advice operated by the FOPH. If the user leaves the app in order to complete the guide, the dates named in the app on which an infection may have taken place are automatically transferred to the guide. + The users thus notified learn that a proximity event has occurred, i.e. that they have potentially been exposed to the coronavirus, and on what dates this was the case. They are not told which user has been infected and has triggered the notification. The notification of the SwissCovid app on the basis of close contact with a user who has tested positive also contains the behavioural recommendations of the FOPH and draws attention to a guide (web form) as well as the Infoline offering free advice operated by the FOPH. If the user leaves the app in order to complete the guide, the dates named in the app on which an infection may have taken place are automatically transferred to the guide. Notifications on the basis of the check-in function cover the above-mentioned points, although here no guide or infoline is offered; the primary content of the notification is [the recommendation to have a test.

      3.5 - Upon entering the activation code, users are made aware of with which countries (see Section 2.2) the app is interoperable. Reference is made to the fact that – if the “Accept” field is tapped and the activation code is entered in the app – the private keys will not only be shared within the SwissCovid app, but rather also with other interoperable apps (currently: the German Corona-Warn-App). The private keys can only be shared with all interoperable apps or not at all, in keeping with the “one world principle”. + Upon entering the activation code, users are made aware of with which countries (see Section 2.2) the app is interoperable. Reference is made to the fact that – if the “Accept” field is tapped and the activation code is entered in the app – the proximity tracing system’s private keys will not only be shared within the SwissCovid app, but rather also with other interoperable apps (currently: the German Corona-Warn-App). The private keys can only be shared with all interoperable apps or not at all, in keeping with the “one world principle”.

      3.6 @@ -184,7 +189,7 @@

      7.3 - At the latest when the Ordinance mentioned in Section 1.2 is no longer in force, the FOPH will deactivate the app and request users to uninstall it from their mobile phone. + At the latest when the ordinances mentioned in Section 1.2 is no longer in force, the FOPH will deactivate the app and request users to uninstall it from their mobile phone.

      8. diff --git a/app/src/main/assets/disclaimer/fr/data_protection_statement.html b/app/src/main/assets/disclaimer/fr/data_protection_statement.html index bb47bf275..f6beb8f70 100644 --- a/app/src/main/assets/disclaimer/fr/data_protection_statement.html +++ b/app/src/main/assets/disclaimer/fr/data_protection_statement.html @@ -7,26 +7,29 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> -

      Utilisation de l’application SwissCovid: déclaration de confidentialité de l’Office fédéral de la santé publique

      - Version : 9 avril 2021 + Version : 1 juillet 2021

      Dans la présente déclaration de confidentialité, l’Office fédéral de la santé publique (OFSP) explique dans quelle mesure il traite les données personnelles en lien avec l’utilisation de l’application SwissCovid (ci-après « l’application ») en suisse. Il ne s’agit pas d’un descriptif exhaustif ; certains éléments spécifiques peuvent être réglés dans des déclarations de confidentialité supplémentaires, des documents similaires, des conditions d’utilisation ou des programmes d’application.

      - La législation sur la protection des données règle le traitement de données personnelles. La législation fédérale sur la protection des données s’applique au traitement des données. La déclaration de confidentialité est en outre conforme à la loi du 28 septembre 2012 sur les épidémies (LEp ; RS + La législation sur la protection des données règle le traitement de données personnelles. Dans ce domaine, la législation fédérale sur la protection des données s’applique. La déclaration de confidentialité est en outre conforme à la loi du 28 septembre 2012 sur les épidémies (LEp ; RS 818.101 - ) et l’ordonnance du 24 juin 2020 sur le système de traçage de proximité pour le coronavirus SARS-CoV-2 (OSTP ; RS + ), à l’ordonnance du 24 juin 2020 sur le système de traçage de proximité pour le coronavirus SARS-CoV-2 (OSTP ; RS 818.101.25 - ). + ), à la loi fédérale du 25 septembre 2020 sur les bases légales des ordonnances du Conseil fédéral visant à surmonter l’épidémie de COVID-19 (loi COVID-19 ; RS + + 808.102 + + ) et à l’ordonnance du 24 juin 2021 sur un système d’alerte COVID-19 pour les manifestations.

      On entend par données personnelles toutes les informations qui se rapportent à une personne identifiée ou identifiable. Par traitement, on entend toute opération relative à des données personnelles – quels que soient les moyens et procédés utilisés – notamment la collecte, la conservation, l’exploitation, la modification, la communication, l’archivage ou la destruction de données. @@ -56,11 +59,15 @@

      Collecte et traitement de données personnelles

      - Tout le système de l’application est conçu de manière à ce que ses utilisateurs ne soient pas identifiables. Le traitement de données personnelles est réduit au minimum. Ainsi, aucune indication technique sur les personnes, les lieux ou les appareils n’est possible. Les géolocalisations ne sont pas enregistrées ; seules des données cryptées relatives aux contacts ayant eu lieu sont saisies. Ces données sont techniquement protégées de toute utilisation abusive. L’OFSP ne peut tirer aucune conclusion concernant les utilisateurs de l’application. Cette dernière protège les données des utilisateurs de telle sorte qu’aucun lien ne peut être établi avec une personne précise sur de longues distances. Un rattachement à un individu ne peut toutefois pas être totalement exclu. Il existe en effet une certaine probabilité qu’une personne alertée d’un possible risque d’infection puisse éventuellement tirer des conclusions sur l’identité de la personne infectée, en se remémorant les contacts sociaux qu’elle a eus au cours des derniers jours. Les personnes qui utilisent l’application sont donc potentiellement identifiables. La notification inclut les règles de conduite recommandées par l’OFSP, l’information selon laquelle la personne a pu être exposée au coronavirus, la mention des jours où cela s’est produit ainsi que l’indication que l’OFSP propose un guide (formulaire en ligne) et une infoline de conseil gratuit. Lorsque l’utilisateur quitte l’application pour remplir le guide, les jours d’infection potentielle indiqués dans l’application sont automatiquement transmis au guide. + Tout le système de l’application est conçu de manière à ce que ses utilisateurs ne soient pas identifiables. Le traitement de données personnelles est réduit au minimum. Ainsi, aucune indication technique sur les personnes, les lieux ou les appareils n’est possible. Les géolocalisations ne sont pas enregistrées lors d’un contact via traçage de proximité ; seules des données cryptées relatives aux contacts ayant eu lieu sont saisies. Le système d’alerte n’enregistre que des données cryptées concernant les manifestations et les risques d’infections qu’elles présentent. Ces données sont techniquement protégées de toute utilisation abusive. L’OFSP ne peut tirer aucune conclusion concernant les utilisateurs de l’application ni décrypter les données. Cette dernière protège les données des utilisateurs de telle sorte qu’aucun lien ne peut être établi avec une personne précise sur de longues distances. Un rattachement à un individu ne peut toutefois pas être totalement exclu, en particulier pour le système de traçage de proximité. Il existe en effet une certaine probabilité qu’une personne alertée d’un possible risque d’infection puisse éventuellement tirer des conclusions sur l’identité de la personne infectée, en se remémorant les contacts sociaux qu’elle a eus au cours des derniers jours. Les personnes qui utilisent l’application sont donc potentiellement identifiables. La notification déclenchée par un contact étroit avec un utilisateur testé positif inclut les règles de conduite recommandées par l’OFSP, l’information selon laquelle la personne a pu être exposée au coronavirus, la mention des jours où cela s’est produit ainsi que l’indication que l’OFSP propose un guide (formulaire en ligne) et une infoline de conseil gratuit. Lorsque l’utilisateur quitte l’application pour remplir le guide, les jours d’infection potentielle indiqués dans l’application sont automatiquement transmis au guide. La notification due à la fonction + + Check-in + + comprend les points ci-dessus, à l’exception du renvoi au guide et à l’infoline ; cette notification vise principalement à inciter la personne contactée à se faire tester.

      • - Le système de l’application se divise en deux composantes : + Le système de l’application se divise en trois composantes :
      • un système pour gérer les données relatives aux situations de rapprochement, comprenant un logiciel que les utilisateurs installent sur leur téléphone portable ainsi qu’un @@ -82,10 +89,21 @@

        backend web - . + ; +

      • +
      • + un système pour gérer les manifestations, comprenant un logiciel que les utilisateurs installent sur leur téléphone portable ainsi qu’un + + backend + + ( + + backend + + manifestations).
      • - Les deux + Les trois backends @@ -107,7 +125,10 @@

        la puissance du signal ;

      • - la date et la durée estimée de la situation de rapprochement. + la date et la durée estimée de la situation de rapprochement ; +
      • +
      • + le code d’identification de la manifestation avec sa date, sa durée et sa description.

      @@ -140,7 +161,25 @@

    - En outre, le système de l’application peut être relié à d’autres systèmes étrangers correspondants si un niveau adéquat de protection de la personnalité est assuré dans l’État concerné (par la législation ou par des garanties suffisantes, notamment contractuelles). Les systèmes étrangers sont ainsi réputés « correspondants » s’ils sont configurés suivant les principes ci-après du système de l’application : + Le + + backend + + manifestations comprend une liste des données suivantes : +

    +
      +
    • + le code d’identification de la manifestation où les participants infectés s’étaient rendus alors qu’ils auraient pu transmettre le virus ; +
    • +
    • + la date de chaque code d’identification de la manifestation ; +
    • +
    • + la période cryptée pertinente du participant infecté par code d’identification de la manifestation. +
    • +
    +

    + En outre, le système de traçage de proximité peut être relié à d’autres systèmes étrangers correspondants si un niveau adéquat de protection de la personnalité est assuré dans l’État concerné (par la législation ou par des garanties suffisantes, notamment contractuelles). Les systèmes étrangers sont ainsi réputés « correspondants » s’ils sont configurés suivant les principes ci-après du système de l’application :

    • @@ -173,7 +212,7 @@

      Buts et bases légales

      - Le système d’application exploité par l’OFSP ainsi que le système de liaison se fondent sur la LEp et l’OSTP. L’application et le traitement des données entrantes par ce moyen servent exclusivement à informer les utilisateurs potentiellement exposés au coronavirus, tout en respectant la protection des données, et à établir des statistiques en lien avec le coronavirus à l’aide de données issues des deux + Le système d’application exploité par l’OFSP ainsi que le système de liaison se fondent sur la LEp, l’OSTP, la loi COVID-19 et l’OSAM. L’application et le traitement des données entrantes par ce moyen servent exclusivement à informer les utilisateurs potentiellement exposés au coronavirus, tout en respectant la protection des données, et à établir des statistiques en lien avec le coronavirus à l’aide de données issues des trois backends @@ -190,7 +229,11 @@

      backend - GR est mise à la disposition de l’application (ou + GR et du + + backend + + manifestations est mise à la disposition de l’application (ou frontend @@ -198,11 +241,11 @@

      a - LEp et de l’OSTP. En est exclue la réglementation concernant le code source en vertu de l’art. 60 + LEp, de l’OSTP, de l’art. 3, al. 7, let. a, de la loi COVID-19 et de l’OSAM. En est exclue la réglementation concernant le code source en vertu de l’art. 60 a - , al. 5, let. e, LEp. L’OFSP contrôle le respect des prescriptions. Les tiers mandatés n’ont pas le droit d’utiliser à des fins personnelles les données secondaires générées durant l’exécution du contrat. Ces données sont analysées seulement par l’OFSP ou par l’OFIT (cf. ch. 8). + , al. 5, let. e, LEp et de l’art. 15 OSAM. L’OFSP contrôle le respect des prescriptions. Les tiers mandatés n’ont pas le droit d’utiliser à des fins personnelles les données secondaires générées durant l’exécution du contrat. Ces données sont analysées seulement par l’OFSP ou par l’OFIT (cf. ch. 8).

      La liste contenant les clés privées des utilisateurs infectés du @@ -212,14 +255,14 @@

      GR est en outre régulièrement transmise au système de liaison pour une information transfrontalière. Ensuite les systèmes étrangers reliés (actuellement l’application Corona-Warn utilisée en Allemagne) téléchargent ces clés privées et les tiennent à disposition de leurs applications pour extraction (cf. ch. 2).

      - L’OFSP met régulièrement à la disposition de l’Office fédéral de la statistique (OFS) le stock actuel de données existantes dans les deux + L’OFSP met régulièrement à la disposition de l’Office fédéral de la statistique (OFS) le stock actuel de données existantes dans les trois backends sous forme anonymisée. Les données du système de liaison peuvent être communiquées sous forme anonymisée à l’OFS et au service étranger compétent à des fins de statistiques. L’OFIT gère le logiciel dans son ensemble sur mandat de l’OFSP et fournit le service de soutien technique requis. L’OFIT a uniquement accès à des données lorsque cela s’avère nécessaire pour les buts et les activités spécifiques des collaborateurs concernés. Ces derniers sont tenus à la confidentialité lorsqu’ils traitent les données.

      - L’application utilise une interface reliée au système d’exploitation du téléphone portable de l’utilisateur, qui nécessite le traitement des données par Apple ou Google. Les fonctionnalités des systèmes d’exploitation utilisées par l’interface doivent remplir les prescriptions de l’art. 60 + Pour le système de traçage de proximité, l’application utilise une interface reliée au système d’exploitation du téléphone portable de l’utilisateur, qui nécessite le traitement des données par Apple ou Google. Les fonctionnalités des systèmes d’exploitation utilisées par l’interface doivent remplir les prescriptions de l’art. 60 a @@ -245,6 +288,13 @@

      GR) : 14 jours après leur saisie ;

    • +
    • + données du système servant à gérer les manifestations (aussi bien dans le téléphone portable que dans le + + backend + + manifestations) : 14 jours après leur saisie ; +
    • données du système des codes : 24 heures après leur saisie ;
    • @@ -282,7 +332,11 @@

      backend - GR et au système de contrôle du code source sont enregistrés pour les buts fixés aux art. 57 + GR, au + + backend + + manifestations et au système de contrôle du code source sont enregistrés pour les buts fixés aux art. 57 l diff --git a/app/src/main/assets/disclaimer/fr/terms_of_use.html b/app/src/main/assets/disclaimer/fr/terms_of_use.html index c5ce324a1..f0541b163 100644 --- a/app/src/main/assets/disclaimer/fr/terms_of_use.html +++ b/app/src/main/assets/disclaimer/fr/terms_of_use.html @@ -7,7 +7,6 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> - 1. Champ d’application et objet @@ -21,11 +20,15 @@ 818.101 -) et sur l’ordonnance du 24 juin 2020 sur le système de traçage de proximité pour le coronavirus SARS-CoV-2 (RS +), l’ordonnance du 24 juin 2020 sur le système de traçage de proximité pour le coronavirus SARS-CoV-2 (RS 818.101.25 -). +), la loi fédérale du 25 septembre 2020 sur les bases légales des ordonnances du Conseil fédéral visant à surmonter l’épidémie de COVID-19 (loi COVID-19 ; RS + + 818.102 + +) et l’ordonnance du 24 juin 2021 sur un système d’alerte COVID-19 pour les manifestations.

      1.3 L’application vise à informer les utilisateurs qui pourraient avoir été exposés au virus et à établir des statistiques en rapport avec le coronavirus. @@ -40,7 +43,7 @@

      2.2 - L’utilisation de l’application n’est pas limitée à une zone géographique. Toutefois, des alertes sont possibles uniquement si l’application étrangère est interopérable avec l’application SwissCovid. Actuellement, seule l’application Corona-Warn utilisée en Allemagne remplit cette condition. + L’utilisation de l’application n’est pas limitée à une zone géographique. Toutefois, des alertes sont possibles uniquement si l’application étrangère est interopérable avec l’application SwissCovid. Actuellement, seule l’application Corona-Warn utilisée en Allemagne est compatible avec le système de traçage de proximité de l’application SwissCovid.

      2.3 @@ -52,18 +55,18 @@

      3.1 - L’application informe les utilisateurs lorsqu’ils ont été en contact avec au moins un utilisateur de l’application SwissCovid ou d’une autre application interopérable dont l’infection a été confirmée. + L’application informe les utilisateurs lorsqu’ils ont été en contact avec au moins un utilisateur de l’application SwissCovid ou d’une autre application interopérable dont l’infection a été confirmée. Ce contact peut être déterminé sur la base du traçage de proximité ou de la participation à une manifestation.

      3.2 - Le Bluetooth doit être activé pour que l’application fonctionne. + Le Bluetooth doit être activé pour que le système de traçage de proximité fonctionne. En revanche, le système d’alerte n’a pas besoin du Bluetooth.

      3.3 - L’application remplit les fonctions suivantes en utilisant une interface liée au système d’exploitation du téléphone portable de l’utilisateur : + L’application remplit les fonctions suivantes en utilisant, pour le système de traçage de proximité, une interface liée au système d’exploitation du téléphone portable de l’utilisateur :

      - Le système d’exploitation génère chaque jour une nouvelle clé privée qui ne permet pas de remonter jusqu’à l’application, le téléphone portable ou à l’utilisateur. Dans le rayon de portée du Bluetooth, le système d’exploitation échange avec toutes les applications compatibles et actives un code d’identification (ID aléatoire) qui change au moins toutes les demi-heures, qui est généré à partir de la clé privée actuelle mais qui ne permet ni d’identifier la clé, ni de remonter jusqu’à l’application, au téléphone portable ou à l’utilisateur. + Système de traçage de proximité : le système d’exploitation génère chaque jour une nouvelle clé privée qui ne permet pas de remonter jusqu’à l’application, le téléphone portable ou à l’utilisateur. Dans le rayon de portée du Bluetooth, le système d’exploitation échange avec toutes les applications compatibles et actives un code d’identification (ID aléatoire) qui change au moins toutes les demi-heures, qui est généré à partir de la clé privée actuelle mais qui ne permet ni d’identifier la clé, ni de remonter jusqu’à l’application, au téléphone portable ou à l’utilisateur.

      Le système d’exploitation enregistre les codes d’identification reçus, la puissance du signal, la date et la durée approximative du rapprochement. @@ -71,17 +74,27 @@

      À intervalles réguliers, l’application extrait la liste des clés privées des utilisateurs infectés de l’application SwissCovid ou d’autres applications interopérables, et le système d’exploitation contrôle si au moins un code d’identification enregistré localement a été généré avec une clé privée. Si tel est le cas et qu’un rapprochement d’un mètre et demi ou moins a été établi avec au moins un téléphone mobile d’au moins un utilisateur infecté de l’application SwissCovid ou d’autres applications interopérables et que la durée totale de ces rapprochements atteint ou dépasse les quinze minutes au cours de la même journée, l’application envoie une information. La distance est évaluée en fonction de l’intensité du signal reçu.

      +

      + Système d’alerte pour les manifestations : l’organisateur d’une manifestation génère un code QR dans son application. Les visiteurs de la manifestation peuvent scanner le code QR présenté avec leur application et indiquent ainsi avoir participé à la manifestation. +

      +

      + L’application enregistre sur le téléphone portable le code d’identification de la manifestation avec sa date, sa durée et sa description. Depuis le + + backend + + manifestations, elle consulte régulièrement les codes d’identification de la manifestation chez les participants infectés. Elle compare ces codes avec ceux qu’elle a stocké localement. Si deux codes sont les mêmes, elle envoie alors une notification. +

      3.4 - Si une infection est attestée chez un utilisateur, les professionnels disposant des droits d’accès (p. ex. les médecins traitants ou les pharmaciens) génèrent un code d’autorisation unique et temporaire (code COVID) et le communiquent à l’utilisateur infecté. Ce dernier peut saisir de manière volontaire le code d’autorisation dans son application. La notification et la saisie du code d’autorisation ne se font qu’avec le consentement explicite de la personne infectée. + Si une infection est attestée chez un utilisateur, les professionnels disposant des droits d’accès (p. ex. les médecins traitants ou les pharmaciens) génèrent un code d’autorisation unique et temporaire (code COVID) et le communiquent à l’utilisateur infecté. Ce dernier peut saisir de manière volontaire le code d’autorisation dans son application. La notification et la saisie du code d’autorisation ne se font qu’avec le consentement explicite de la personne infectée. Cette dernière peut par ailleurs choisir si elle souhaite alerter non seulement les contacts étroits détectés par le système de traçage de proximité mais également ceux détectés en raison de leur présence à une manifestation. Elle peut aussi décider de déclencher une notification pour toutes les manifestations auxquelles elle a participé pendant la période concernée ou seulement pour certaines d’entre elles.

      - L’application informe les autres utilisateurs de l’application SwissCovid ou d'autres applications interopérables qui se sont trouvés en rapprochement selon le ch. 3.3 avec l’utilisateur infecté durant la période potentiellement infectieuse. + L’application informe les autres utilisateurs de l’application SwissCovid ou d'autres applications interopérables qui se sont trouvés en rapprochement selon le ch. 3.3 avec l’utilisateur infecté durant la période potentiellement infectieuse ou qui ont participé à une manifestation au même moment.

      - Les utilisateurs informés apprennent qu’un rapprochement a eu lieu ou qu’ils ont potentiellement été exposés au coronavirus ainsi que les jours où cela s’est produit. Ils n’apprennent pas quel utilisateur est infecté et a déclenché la notification. Celle-ci comprend les règles de conduite recommandées par l’OFSP, l'indication que l'OFSP propose un guide (formulaire en ligne) ainsi qu'une infoline de conseil gratuit. Lorsqu'on quitte l’application pour remplir le guide, les jours d’infection potentielle indiqués dans l’application sont automatiquement transmis au guide. + Les utilisateurs informés apprennent qu’un rapprochement a eu lieu ou qu’ils ont potentiellement été exposés au coronavirus ainsi que les jours où cela s’est produit. Ils n’apprennent pas quel utilisateur est infecté et a déclenché la notification. La notification due à un contact étroit avec un utilisateur testé positif comprend les règles de conduite recommandées par l’OFSP, l'indication que l'OFSP propose un guide (formulaire en ligne) ainsi qu'une infoline de conseil gratuit. Lorsqu'on quitte l’application pour remplir le guide, les jours d’infection potentielle indiqués dans l’application sont automatiquement transmis au guide. La notification due au système d’alerte comprend les points ci-dessus, à l’exception du renvoi au guide et à l’infoline ; cette notification vise principalement à recommander à la personne contactée à se faire tester.

      -Lors de la saisie des codes d’autorisation, les utilisateurs sont informés des pays avec lesquels l’application est interopérable (cf. ch. 2.2). Il leur est précisé que s’ils appuient sur le champ « D’accord » et qu’ils saisissent le code d’autorisation dans l’application, les clés privées ne sont pas seulement partagées dans l’application SwissCovid mais également avec toutes les autres applications interopérables (actuellement l’application allemande Corona-Warn). Suivant le principe +Lors de la saisie des codes d’autorisation, les utilisateurs sont informés des pays avec lesquels l’application est interopérable (cf. ch. 2.2). Il leur est précisé que s’ils appuient sur le champ « D’accord » et qu’ils saisissent le code d’autorisation dans l’application, les clés privées du système de traçage ce proximité ne sont pas seulement partagées dans l’application SwissCovid mais également avec toutes les autres applications interopérables (actuellement l’application allemande Corona-Warn). Suivant le principe One World @@ -192,7 +205,7 @@

      7.3 - Au plus tard au moment du retrait de l’ordonnance mentionnée au ch. 1.2, l’OFSP désactive l’application et prie les utilisateurs de la désinstaller de leur téléphone portable. + Au plus tard au moment du retrait des ordonnances mentionnées au ch. 1.2, l’OFSP désactive l’application et prie les utilisateurs de la désinstaller de leur téléphone portable.

      8. diff --git a/app/src/main/assets/disclaimer/it/data_protection_statement.html b/app/src/main/assets/disclaimer/it/data_protection_statement.html index 5273b02c4..eac51f22b 100644 --- a/app/src/main/assets/disclaimer/it/data_protection_statement.html +++ b/app/src/main/assets/disclaimer/it/data_protection_statement.html @@ -7,18 +7,17 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> -

      Informativa sulla protezione dei dati dell’Ufficio federale della sanità pubblica UFSP in relazione all’utilizzo dell’«app SwissCovid»

      - Versione: 9 aprile 2021 + Versione: 1° luglio 2021

      Nella presente informativa sulla protezione dei dati l’Ufficio federale della sanità pubblica (UFSP) spiega in che misura tratterà dati personali nel quadro dell’utilizzo dell’applicazione «app SwissCovid» (di seguito app) in Svizzera. Non si tratta di una descrizione esaustiva; alcuni aspetti specifici possono essere regolamentati da altre informative sulla protezione dei dati, documenti analoghi, condizioni di utilizzo o programmi di applicazione.

      - Il trattamento dei dati personali è disciplinato dal diritto sulla protezione dei dati; al trattamento dei dati si applica la legislazione federale in materia. La presente informativa è inoltre conforme alla legge del 28 settembre 2012 sulle epidemie (LEp; RS 818.101) e all’ordinanza del 24 giugno 2020 sul sistema di tracciamento della prossimità per il coronavirus SARS-CoV-2 (OSTP; RS 818.101.25). + Il trattamento dei dati personali è disciplinato dal diritto sulla protezione dei dati; al trattamento dei dati personali si applica la legislazione federale in materia. La presente informativa è inoltre conforme alla legge del 28 settembre 2012 sulle epidemie (LEp; RS 818.101), all’ordinanza del 24 giugno 2020 sul sistema di tracciamento della prossimità per il coronavirus SARS-CoV-2 (OSTP; RS 818.101.25), alla legge federale del 25 settembre 2020 sulle basi legali delle ordinanze del Consiglio federale volte a far fronte all’epidemia di COVID-19 (legge COVID-19; RS 818.102) nonché all’ordinanza del 24 giugno 2021 su un sistema di allerta COVID-19 per le manifestazioni.

      Per dati personali s’intendono tutte le informazioni relative a una persona identificata o identificabile. Il trattamento indica qualsiasi operazione relativa a dati personali, indipendentemente dai mezzi e dalle procedure impiegati, segnatamente la raccolta, la conservazione, l’utilizzazione, la modificazione, la comunicazione, l’archiviazione o la distruzione di dati. @@ -48,20 +47,23 @@

      Raccolta e trattamento dei dati personali

      - L’intero sistema dell’app è concepito in modo tale che l’utente non sia identificabile. Il trattamento dei dati personali è ridotto al minimo, per cui non è in alcun modo tecnicamente possibile risalire a persone, luoghi o dispositivi. Non vengono rilevati dati sulla posizione, ma soltanto informazioni crittografate riguardanti i contatti avvenuti, protette tecnicamente da usi impropri. L’UFSP non può risalire agli utenti dell’app, che ne protegge i dati impedendo di stabilire un collegamento con una data persona sulle lunghe distanze. Un collegamento a una data persona non può tuttavia essere completamente escluso. Esiste infatti una certa probabilità che una persona informata di essere potenzialmente a rischio possa risalire all’identità della persona contagiata ricostruendo i contatti sociali avuti nei giorni precedenti. L’utilizzo dell’app potrebbe quindi permettere l’identificazione di persone. La notifica include le raccomandazioni di comportamento dell’UFSP, l’informazione che l’utente è stato potenzialmente esposto al coronavirus, l’indicazione dei giorni in cui lo è stato, la segnalazione che l’UFSP gestisce una guida (modulo web) e una linea di consulenza telefonica gratuita. Se l’utente esce dall’app per compilare il modulo web, i giorni menzionati nell’app nei quali c’è stata la possibilità di contagio vengono trasmessi automaticamente al modulo. + L’intero sistema dell’app è concepito in modo tale che l’utente non sia identificabile. Il trattamento dei dati personali è ridotto al minimo, per cui non è in alcun modo tecnicamente possibile risalire a persone, luoghi o dispositivi. Non vengono registrati dati sulla posizione degli incontri rilevati tramite tracciamento di prossimità, ma soltanto informazioni criptate riguardanti i contatti avvenuti. Il sistema di allerta registra solo dati criptati sulle manifestazioni e sui rischi di contagio in occasione di tali manifestazioni. Questi dati sono protetti tecnicamente da usi impropri. L’UFSP non può risalire agli utenti dell’app né decrittare i dati, che l’app protegge impedendo di stabilire un collegamento con una data persona sulle lunghe distanze. Un collegamento a una data persona non può tuttavia essere completamente escluso. Esiste infatti una certa probabilità, in particolare con il sistema di tracciamento della prossimità, che una persona informata di essere potenzialmente a rischio possa risalire all’identità della persona contagiata ricostruendo i contatti sociali avuti nei giorni precedenti. L’utilizzo dell’app potrebbe quindi permettere l’identificazione di persone. La notifica in base a un contatto stretto con un utente risultato positivo al test include le raccomandazioni di comportamento dell’UFSP, l’informazione che l’utente è stato potenzialmente esposto al coronavirus, l’indicazione dei giorni in cui lo è stato, la segnalazione che l’UFSP gestisce una guida (modulo web) e una linea di consulenza telefonica gratuita. Se l’utente esce dall’app per compilare il modulo web, i giorni menzionati nell’app nei quali c’è stata la possibilità di contagio vengono trasmessi automaticamente al modulo. La notifica in base alla funzione di check-in include i punti precedentemente menzionati, ma non l’offerta di una guida, né di una linea di consulenza telefonica, e verte principalmente sulla raccomandazione di sottoporsi al test.

      • - Il sistema dell’app è costituito da due componenti: + Il sistema dell’app è costituito da tre componenti:
      • un sistema di gestione dei dati di prossimità, costituito da un software che viene installato dagli utenti sul proprio telefono cellulare e da un back end (back end GP);
      • - un sistema di gestione dei codici di attivazione delle informazioni (sistema di gestione dei codici), costituito da un front end e un back end basati sul web. + un sistema di gestione dei codici di attivazione delle informazioni (sistema di gestione dei codici), costituito da un front end e un back end basati sul web;
      • - Entrambi i back end, che fungono da server centrali, sono sotto il controllo diretto dell’UFSP e sono tecnicamente gestiti dall’Ufficio federale dell’informatica e della telecomunicazione (UFIT). I front end per la gestione dei codici funzionano sui dispositivi degli specialisti autorizzati a generare il codice di attivazione (codice Covid). + un sistema di gestione delle manifestazioni, costituito da un software che viene installato dagli utenti sul proprio telefono cellulare e da un back end (back end manifestazioni). +
      • +
      • + I tre back end, che fungono da server centrali, sono sotto il controllo diretto dell’UFSP e sono tecnicamente gestiti dall’Ufficio federale dell’informatica e della telecomunicazione (UFIT). I front end per la gestione dei codici funzionano sui dispositivi degli specialisti autorizzati a generare il codice di attivazione (codice Covid).

      @@ -69,13 +71,16 @@

      • - codici di identificazione (cosiddetti ID casuali) ricevuti da altri telefoni cellulari con l’app attivata; + i codici di identificazione (cosiddetti ID casuali) ricevuti da altri telefoni cellulari con l’app attivata;
      • - potenza del segnale; + la potenza del segnale;
      • - data e durata stimata della prossimità. + la data e la durata stimata della prossimità; +
      • +
      • + i codici di identificazione delle manifestazioni con la rispettiva data, la durata della permanenza e la designazione della manifestazione.

      @@ -104,7 +109,21 @@

    - Inoltre, il sistema dell’app può essere collegato a sistemi esteri corrispondenti se nello Stato in questione è garantita un’adeguata protezione della personalità (grazie alla legislazione o a sufficienti garanzie, in particolare contrattuali). I sistemi esteri sono considerati «corrispondenti» se sono concepiti in base ai seguenti principi del sistema dell’app: + Il back end manifestazioni è costituito da un elenco con i seguenti dati: +

    +
      +
    • + i codici di identificazione delle manifestazioni dei partecipanti infetti rilevanti nel periodo in cui è stato possibile un contagio di altre persone durante una manifestazione; +
    • +
    • + la data di ogni codice di identificazione della manifestazione; +
    • +
    • + il periodo rilevante criptato del partecipante infetto per ogni codice di identificazione della manifestazione. +
    • +
    +

    + Inoltre, il sistema di tracciamento della prossimità può essere collegato a sistemi esteri corrispondenti se nello Stato in questione è garantita un’adeguata protezione della personalità (grazie alla legislazione o a sufficienti garanzie, in particolare contrattuali). I sistemi esteri sono considerati «corrispondenti» se sono concepiti in base ai seguenti principi del sistema dell’app:

    • @@ -129,7 +148,7 @@

      Scopi e basi giuridiche

      - Il sistema dell’app gestito dall’UFSP e il sistema di collegamento si fondano sulla LEp e sull’OSTP. L’app e i relativi dati trattati servono unicamente a informare, nel rispetto della protezione dei dati, gli utenti che sono stati potenzialmente esposti al coronavirus e a elaborare statistiche in relazione al coronavirus a partire dai dati dei due back end. + Il sistema dell’app gestito dall’UFSP e il sistema di collegamento si fondano sulla LEp, sull’OSTP, sulla legge COVID-19 e sull’OSAM. L’app e i relativi dati trattati servono unicamente a informare, nel rispetto della protezione dei dati, gli utenti che sono stati potenzialmente esposti al coronavirus e a elaborare statistiche in relazione al coronavirus a partire dai dati dei tre back end.

      Il sistema di collegamento fa sì che una simile informazione sia possibile anche tra app nazionali collegate. In tal modo, gli utenti dell’app possono essere informati anche se si sono trovati in prossimità di utenti infetti di un’app estera (p. es. frontalieri e turisti in Svizzera o contatti all’estero). Viceversa, anche gli utenti di un’app estera collegata possono essere informati se si sono trovati in prossimità di utenti infetti dell’app. @@ -140,24 +159,24 @@

      Trasmissione dei dati

      - L’elenco con i dati del back end GP viene messo a disposizione dell’app mediante procedura di richiamo. Laddove l’UFSP commissioni tali servizi a terzi situati in Svizzera o all’estero, questi ultimi s’impegnano contrattualmente a rispettare le prescrizioni dell’articolo 60 + L’elenco con i dati del back end GP e del back end manifestazioni viene messo a disposizione dell’app mediante procedura di richiamo. Laddove l’UFSP commissioni tali servizi a terzi situati in Svizzera o all’estero, questi ultimi s’impegnano contrattualmente a rispettare le prescrizioni dell’articolo 60 a - LEp e dell’OSTP; è fatta salva la disposizione sul codice sorgente di cui all’articolo 60 + LEp, dell’OSTP, dell’articolo 3 capoverso 7 lettera a legge COVID-19 nonché dell’OSAM; è fatta salva la disposizione sul codice sorgente di cui all’articolo 60 a - capoverso 5 lettera e LEp. L’UFSP controlla che questi terzi rispettino le prescrizioni e non utilizzino per scopi propri i metadati generati durante l’esecuzione del mandato. Questi dati sono analizzati soltanto dall’UFSP o dall’UFIT (cfr. n. 8). + capoverso 5 lettera e LEp e all’articolo 15 OSAM. L’UFSP controlla che questi terzi rispettino le prescrizioni e non utilizzino per scopi propri i metadati generati durante l’esecuzione del mandato. Questi dati sono analizzati soltanto dall’UFSP o dall’UFIT (cfr. n. 8).

      L’elenco delle chiavi private degli utenti infetti del back end GP è inoltre trasmesso regolarmente al sistema di collegamento ai fini dell’informazione transfrontaliera. I sistemi esteri collegati (attualmente: la Corona-Warn-App tedesca) scaricano quindi queste chiavi private e le mettono a disposizione delle proprie app affinché possano richiamarle (cfr. n. 2).

      - L’UFSP mette periodicamente a disposizione dell’Ufficio federale di statistica (UST) per valutazioni statistiche l’attuale raccolta di dati disponibili nei due back end in forma anonimizzata. I dati del sistema di collegamento possono essere resi noti in forma completamente anonimizzata all’UST e al competente servizio estero a fini statistici. L’UFIT gestisce su incarico dell’UFSP tutti i software e fornisce il servizio di assistenza tecnica necessario. L’UFIT ha accesso solo ai dati necessari agli scopi descritti e all’attività dei collaboratori interessati, che sono tenuti a utilizzare i dati con riservatezza. + L’UFSP mette periodicamente a disposizione dell’Ufficio federale di statistica (UST) per valutazioni statistiche l’attuale raccolta di dati disponibili nei tre back end in forma anonimizzata. I dati del sistema di collegamento possono essere resi noti in forma completamente anonimizzata all’UST e al competente servizio estero a fini statistici. L’UFIT gestisce su incarico dell’UFSP tutti i software e fornisce il servizio di assistenza tecnica necessario. L’UFIT ha accesso solo ai dati necessari agli scopi descritti e all’attività dei collaboratori interessati, che sono tenuti a utilizzare i dati con riservatezza.

      - L’app utilizza un’interfaccia verso il sistema operativo del telefono cellulare dell’utente che elabora dati attraverso Apple o Google. Le funzioni dei sistemi operativi utilizzate tramite l’interfaccia devono adempiere le prescrizioni di cui all’articolo 60 + Per il sistema di tracciamento della prossimità, l’app utilizza un’interfaccia verso il sistema operativo del telefono cellulare dell’utente che elabora dati attraverso Apple o Google. Le funzioni dei sistemi operativi utilizzate tramite l’interfaccia devono adempiere le prescrizioni di cui all’articolo 60 a @@ -177,6 +196,9 @@

    • dati del sistema di gestione dei dati di prossimità (sia nei telefoni cellulari sia nel back end GP): 14 giorni dopo la loro registrazione;
    • +
    • + dati del sistema di gestione delle manifestazioni (sia nei telefoni cellulari sia nel back end manifestazioni): 14 giorni dopo la loro registrazione; +
    • dati del sistema di gestione dei codici: 24 ore dopo la loro registrazione;
    • @@ -207,7 +229,7 @@

      Varie

      - Vengono memorizzati i registri degli accessi al back end GP e al sistema di gestione dei codici per gli scopi previsti agli articoli 57 + Vengono memorizzati i registri degli accessi al back end GP, al back end manifestazioni e al sistema di gestione dei codici per gli scopi previsti agli articoli 57 l diff --git a/app/src/main/assets/disclaimer/it/terms_of_use.html b/app/src/main/assets/disclaimer/it/terms_of_use.html index 8223a8e96..9cd394a22 100644 --- a/app/src/main/assets/disclaimer/it/terms_of_use.html +++ b/app/src/main/assets/disclaimer/it/terms_of_use.html @@ -7,7 +7,6 @@ ~ ~ SPDX-License-Identifier: MPL-2.0 --> - 1. Campo di applicazione e scopo @@ -18,7 +17,7 @@

      1.2 - L’app dell’Ufficio federale della sanità pubblica (UFSP) si basa sulla legge del 28 settembre 2012 (LEp; RS 818.101) e sull’ordinanza del 24 giugno 2020 sul sistema di tracciamento della prossimità per il coronavirus SARS-CoV-2 (OSTP; RS 818.101.25). + L’app dell’Ufficio federale della sanità pubblica (UFSP) si basa sulla legge del 28 settembre 2012 (LEp; RS 818.101), sull’ordinanza del 24 giugno 2020 sul sistema di tracciamento della prossimità per il coronavirus SARS-CoV-2 (OSTP; RS 818.101.25), sulla legge federale del 25 settembre 2020 sulle basi legali delle ordinanze del Consiglio federale volte a far fronte all’epidemia di COVID-19 (legge COVID-19; RS 818.102) nonché sull’ordinanza del 24 giugno 2021 su un sistema di allerta COVID-19 per le manifestazioni.

      1.3 @@ -34,7 +33,7 @@

      2.2 - L’utilizzo dell’app non è limitato a una regione geografica. Tuttavia, le segnalazioni possono aver luogo soltanto se l’app estera è interoperabile con l’app SwissCovid. Al momento attuale lo è soltanto la Corona-Warn-App impiegata in Germania. + L’utilizzo dell’app non è limitato a una regione geografica. Tuttavia, le segnalazioni possono aver luogo soltanto se l’app estera è interoperabile con l’app SwissCovid. Al momento attuale soltanto la Corona-Warn-App impiegata in Germania è interoperabile con il sistema di tracciamento della prossimità dell’app SwissCovid.

      2.3 @@ -46,38 +45,44 @@

      3.1 - L’app informa gli utenti che sono entrati in stretto contatto con almeno un utente dell’app SwissCovid o delle altre app interoperabili risultato contagiato. + L’app informa gli utenti che sono entrati in stretto contatto con almeno un utente dell’app SwissCovid o delle altre app interoperabili risultato infetto. Questo contatto rilevante può essere ricostruito in base al tracciamento della prossimità o alla partecipazione a una manifestazione.

      3.2 - Affinché l’app funzioni è necessario che il Bluetooth sia attivo. + Il sistema di tracciamento della prossimità dell’app presuppone l’attivazione del Bluetooth; per il sistema di allerta ciò non è invece necessario.

      3.3 - Mediante l’utilizzazione di un’interfaccia verso il sistema operativo del telefono cellulare dell’utente, l’app adempie le seguenti funzioni. + Con il sistema di tracciamento della prossimità e mediante l’utilizzazione di un’interfaccia verso il sistema operativo del telefono cellulare dell’utente, l’app adempie le seguenti funzioni.

      - Il sistema operativo genera ogni giorno una nuova chiave privata che non consente di risalire in alcun modo all’app, al telefono cellulare o agli utenti. Scambia un codice d’identificazione (cosiddetto “random ID”) che cambia almeno una volta ogni mezz’ora con tutte le app compatibili e attive che si trovano entro la portata di Bluetooth. Tale codice deriva da una chiave privata aggiornata, ma non è riconducibile alla stessa e di conseguenza nemmeno all’app, al telefono cellulare e al suo utente. + Sistema di tracciamento della prossimità: il sistema operativo genera ogni giorno una nuova chiave privata che non consente di risalire in alcun modo all’app, al telefono cellulare o agli utenti. Scambia un codice d’identificazione (cosiddetto “random ID”) che cambia almeno una volta ogni mezz’ora con tutte le app compatibili e attive che si trovano entro la portata di Bluetooth. Tale codice deriva da una chiave privata aggiornata, ma non è riconducibile alla stessa e di conseguenza nemmeno all’app, al telefono cellulare e al suo utente.

      Il sistema operativo memorizza sul telefono cellulare i codici d’identificazione ricevuti, la potenza del segnale, la data e la durata stimata della prossimità.

      - L’app richiama periodicamente un elenco delle chiavi private degli utenti contagiati dell’app SwissCovid e delle altre app interoperabili e controlla con il suo sistema operativo se almeno un codice d’identificazione memorizzato localmente è stato generato con una chiave privata dell’elenco. Se ciò si verifica nonché la prossimità da almeno un telefono cellulare di un utente dell’app SwissCovid o delle altre app interoperabili contagiato è pari o inferiore a 1,5 metri e la somma della durata di tutte queste prossimità in un giorno raggiunge i quindici minuti, l’app ne informa l’utente. La distanza è stimata in base alla potenza del segnale ricevuto. + L’app richiama periodicamente un elenco delle chiavi private degli utenti infetti dell’app SwissCovid e delle altre app interoperabili e controlla con il suo sistema operativo se almeno un codice d’identificazione memorizzato localmente è stato generato con una chiave privata dell’elenco. Se ciò si verifica nonché la prossimità da almeno un telefono cellulare di un utente dell’app SwissCovid o delle altre app interoperabili infetto è pari o inferiore a 1,5 metri e la somma della durata di tutte queste prossimità in un giorno raggiunge i quindici minuti, l’app ne informa l’utente. La distanza è stimata in base alla potenza del segnale ricevuto. +

      +

      + Sistema di allerta per le manifestazioni: l’organizzatore di una manifestazione genera nella propria app un codice QR che i visitatori della manifestazione possono scansionare per effettuare il check-in alla manifestazione. +

      +

      + L’app memorizza sul telefono cellulare il codice di identificazione della manifestazione con la rispettiva data, la durata della permanenza e la designazione della manifestazione. Richiama periodicamente dal back end delle manifestazioni l’elenco dei codici di identificazione delle manifestazioni dei partecipanti infetti. Confronta tali codici con i codici di identificazione delle manifestazioni che ha memorizzato localmente. Se da tale confronto emerge una corrispondenza, genera una notifica.

      3.4 - In caso di contagio accertato di un utente, il personale specialistico avente diritto di accesso (p. es. medici curanti o farmacisti) genera un codice di attivazione (codice Covid) univoco e la cui validità è limitata nel tempo e lo comunica all’utente contagiato, che può immetterlo nell’app su base volontaria. La notifica ovvero l’inserimento del codice di attivazione richiedono il consenso esplicito dell'utente contagiato. + In caso di infezione accertata di un utente, il personale specialistico avente diritto di accesso (p. es. medici curanti o farmacisti) genera un codice di attivazione (codice Covid) univoco e la cui validità è limitata nel tempo e lo comunica all’utente infetto, che può immetterlo nell’app su base volontaria. La notifica ovvero l’inserimento del codice di attivazione richiedono il consenso esplicito dell’utente infetto. L’utente infetto può scegliere se allertare sia i contatti stretti in base al sistema di tracciamento della prossimità sia i contatti in base alla partecipazione a una manifestazione. Può inoltre decidere se generare una notifica per tutte le manifestazioni alle quali ha partecipato nel periodo rilevante o solo per alcune.

      - Gli altri utenti dell’app SwissCovid o delle altre app interoperabili che sono stati in prossimità secondo il numero 3.3 con l’utente contagiato durante il periodo di contagiosità vengono informati dalla loro app. + Gli altri utenti dell’app SwissCovid o delle altre app interoperabili che sono stati in prossimità secondo il numero 3.3 con l’utente infetto durante il periodo di infettività o che hanno partecipato nello stesso momento alla medesima manifestazione vengono informati dalla loro app.

      - Viene loro comunicato che vi è stata una prossimità ovvero che sono stati potenzialmente esposti al coronavirus e le date in cui tale episodio si è verificato. Non viene comunicato loro quale utente è risultato contagiato e ha attivato la notifica dell’app SwissCovid. Quest’ultima comprende anche le raccomandazioni di comportamento dell’UFSP nonché l’informazione che l’UFSP gestisce una guida (modulo web) e una linea di consulenza telefonica gratuita. Se l’utente esce dall’app per compilare il modulo web, i giorni menzionati nell’app nei quali c’è stata la possibilità di contagio vengono trasmessi automaticamente al modulo. + Viene loro comunicato che vi è stata una prossimità ovvero che sono stati potenzialmente esposti al coronavirus e le date in cui tale episodio si è verificato. Non viene comunicato loro quale utente è risultato infetto e ha attivato la notifica dell’app SwissCovid. La notifica in base a un contratto stretto con un utente risultato positivo al test comprende anche le raccomandazioni di comportamento dell’UFSP nonché l’informazione che l’UFSP gestisce una guida (modulo web) e una linea di consulenza telefonica gratuita. Se l’utente esce dall’app per compilare il modulo web, i giorni menzionati nell’app nei quali c’è stata la possibilità di contagio vengono trasmessi automaticamente al modulo. La notifica in base al sistema di allerta include i punti precedentemente menzionati, ma non l’offerta di una guida, né di una linea di consulenza telefonica, e verte principalmente sulla raccomandazione di sottoporsi al test.

      3.5 - Al momento dell’immissione del codice di attivazione, agli utenti viene comunicato con quali Paesi è interoperabile l’app (cfr. n. 2.2). Viene inoltre segnalato che, premendo il pulsante «Accetta» e immettendo il codice di attivazione nell’app, le chiavi private vengono condivise non soltanto all’interno dell’app SwissCovid, ma anche con tutte le app interoperabili (attualmente la Corona-Warn-App tedesca). Le chiavi private possono solo essere condivise con tutte le app interoperabili o non essere condivise affatto, secondo il «principio one world». + Al momento dell’immissione del codice di attivazione, agli utenti viene comunicato con quali Paesi è interoperabile l’app (cfr. n. 2.2). Viene inoltre segnalato che, premendo il pulsante «Accetta» e immettendo il codice di attivazione nell’app, le chiavi private del sistema di tracciamento della prossimità vengono condivise non soltanto all’interno dell’app SwissCovid, ma anche con tutte le app interoperabili (attualmente la Corona-Warn-App tedesca). Le chiavi private possono solo essere condivise con tutte le app interoperabili o non essere condivise affatto, secondo il «principio one world».

      3.6 @@ -184,7 +189,7 @@

      7.3 - Al più tardi quando l’ordinanza indicata al numero 1.2 sarà abrogata, l’UFSP disattiverà l’app e inviterà gli utenti a disinstallarla dal proprio telefono cellulare. + Al più tardi quando le ordinanze indicate al numero 1.2 saranno abrogate, l’UFSP disattiverà l’app e inviterà gli utenti a disinstallarla dal proprio telefono cellulare.

      8. diff --git a/app/src/main/assets/impressum/bs/impressum.html b/app/src/main/assets/impressum/bs/impressum.html index 25d8899cc..877ba8633 100644 --- a/app/src/main/assets/impressum/bs/impressum.html +++ b/app/src/main/assets/impressum/bs/impressum.html @@ -105,33 +105,6 @@

      Licence i zasluge

      Upozorenje: Ova aplikacija vas ne štiti od infekcije virusom COVID-19.

      Ova aplikacija je namenjena za praćenje kontakata i za upozoravanje korisnika o mogućem kontaktu sa osobama koje su inficirane virusom COVID-19. Ona nije dijagnostička alatka. Pažljivo pročitajte uputstva na svakom ekranu aplikacije.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/de/impressum.html b/app/src/main/assets/impressum/de/impressum.html index d12831f43..5d6e51069 100644 --- a/app/src/main/assets/impressum/de/impressum.html +++ b/app/src/main/assets/impressum/de/impressum.html @@ -105,33 +105,6 @@

      Lizenzen & Credits

      Warnung: Diese App schützt Sie nicht vor einer COVID-19-Infektion.

      Die App dient dem Proximity Tracing und zur Warnung der Benutzer vor einer möglichen Ansteckung nach einer Begegnung mit einer mit COVID-19 infizierten Person. Es ist auch kein diagnostisches Werkzeug. Bitte lesen Sie die Anweisungen, die in jedem App-Screen enthalten sind, sorgfältig durch.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/en/impressum.html b/app/src/main/assets/impressum/en/impressum.html index 817d40af3..d77237831 100644 --- a/app/src/main/assets/impressum/en/impressum.html +++ b/app/src/main/assets/impressum/en/impressum.html @@ -105,33 +105,6 @@

      Licences & credits

      Warning: This app does not protect you from COVID-19 infection.

      The app is intended for proximity tracing and to warn users of potential exposure to someone infected by COVID-19. It is not a diagnostic tool either. Please read the instructions provided in each app screen carefully.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/es/impressum.html b/app/src/main/assets/impressum/es/impressum.html index 5d7dd8017..c9ed9ec10 100644 --- a/app/src/main/assets/impressum/es/impressum.html +++ b/app/src/main/assets/impressum/es/impressum.html @@ -105,33 +105,6 @@

      Licencias y créditos

      Advertencia: Esta aplicación no le protege de una infección con COVID-19.

      La aplicación está pensada para el rastreo de proximidad y para advertir a los usuarios de un posible riesgo de exposición a una persona contagiada con el COVID-19. No se trata de un instrumento de diagnóstico. Lea cuidadosamente las instrucciones dadas en todas las pantallas.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/fr/impressum.html b/app/src/main/assets/impressum/fr/impressum.html index 8dda0886c..8078e6097 100644 --- a/app/src/main/assets/impressum/fr/impressum.html +++ b/app/src/main/assets/impressum/fr/impressum.html @@ -105,33 +105,6 @@

      Licences et crédits

      Avertissement : cette application ne vous protège pas contre une infection par le COVID-19.

      L'application est destinée au traçage de proximité et à avertir les utilisateurs d'un contact potentiel avec une personne infectée par le COVID-19. Il ne s'agit pas non plus d'un outil de diagnostic. Veuillez lire attentivement les instructions qui apparaissent dans chaque fenêtre de l'application.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/hr/impressum.html b/app/src/main/assets/impressum/hr/impressum.html index 1c8725cee..53761194a 100644 --- a/app/src/main/assets/impressum/hr/impressum.html +++ b/app/src/main/assets/impressum/hr/impressum.html @@ -105,33 +105,6 @@

      Licence i zasluge

      Upozorenje: Ova aplikacija vas ne štiti od infekcije virusom COVID-19.

      Ova aplikacija je namenjena za praćenje kontakata i za upozoravanje korisnika o mogućem kontaktu sa osobama koje su inficirane virusom COVID-19. Ona nije dijagnostička alatka. Pažljivo pročitajte uputstva na svakom ekranu aplikacije.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/img/ce-marking-dark.svg b/app/src/main/assets/impressum/img/ce-marking-dark.svg deleted file mode 100644 index 1a17bc62b..000000000 --- a/app/src/main/assets/impressum/img/ce-marking-dark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/assets/impressum/img/ce-marking.svg b/app/src/main/assets/impressum/img/ce-marking.svg deleted file mode 100644 index 5bc450e68..000000000 --- a/app/src/main/assets/impressum/img/ce-marking.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/assets/impressum/img/manufacturer-iso-icon-dark.svg b/app/src/main/assets/impressum/img/manufacturer-iso-icon-dark.svg deleted file mode 100644 index 2b19bca26..000000000 --- a/app/src/main/assets/impressum/img/manufacturer-iso-icon-dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/assets/impressum/img/manufacturer-iso-icon.svg b/app/src/main/assets/impressum/img/manufacturer-iso-icon.svg deleted file mode 100644 index 9faffa655..000000000 --- a/app/src/main/assets/impressum/img/manufacturer-iso-icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/assets/impressum/it/impressum.html b/app/src/main/assets/impressum/it/impressum.html index cc51e45eb..45b918ce1 100644 --- a/app/src/main/assets/impressum/it/impressum.html +++ b/app/src/main/assets/impressum/it/impressum.html @@ -105,33 +105,6 @@

      Licenze e riconoscimenti

      Attenzione: questa app non ti protegge dalla COVID-19.

      Questa app serve per il tracciamento di prossimità e per avvertire gli utenti che sono stati potenzialmente esposti a una persona infetta da COVID-19. Non è nemmeno uno strumento diagnostico. Leggi attentamente le istruzioni fornite da ciascuna schermata dell'app.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/pt/impressum.html b/app/src/main/assets/impressum/pt/impressum.html index 19af8bba4..b1082f4a3 100644 --- a/app/src/main/assets/impressum/pt/impressum.html +++ b/app/src/main/assets/impressum/pt/impressum.html @@ -105,33 +105,6 @@

      Licenças e créditos

      Aviso: Esta app não o protege de uma infeção com COVID-19.

      A app destina-se a rastrear contactos e advertir os utilizadores de um possível contacto com uma pessoa infetada com COVID-19. Também não se trata de uma ferramenta de diagnóstico. Leia cuidadosamente as instruções apresentadas em todos os ecrãs da app.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/rm/impressum.html b/app/src/main/assets/impressum/rm/impressum.html index d8472f231..8a188dcd3 100644 --- a/app/src/main/assets/impressum/rm/impressum.html +++ b/app/src/main/assets/impressum/rm/impressum.html @@ -105,33 +105,6 @@

      Licenzas & credits

      Avertiment: Quest'app n'As protegia betg d'ina infecziun cun COVID-19.

      L'app è concepida per la reconstrucziun dals contacts e per avertir ils utilisaders en cas d'ina exposiziun potenziala ad ina persuna infectada cun COVID-19. Ella n'è betg in instrument diagnostic. Per plaschair legi cun attenziun las instrucziuns en mintga visur da l'app.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/sq/impressum.html b/app/src/main/assets/impressum/sq/impressum.html index 8d4b1596b..8425bfc5c 100644 --- a/app/src/main/assets/impressum/sq/impressum.html +++ b/app/src/main/assets/impressum/sq/impressum.html @@ -105,33 +105,6 @@

      Licencat dhe mirënjohjet

      Paralajmërim: Ky aplikacion nuk ju mbron nga infektimi me COVID 19.

      Aplikacioni shërben për gjurmimin Proximity dhe për paralajmërimin e përdoruesit nga një infektim i mundshëm pas një kontakti me një person të infektuar me COVID 19. Gjithashtu nuk është mjet diagnostikues. Lexoni me kujdes udhëzimet, të cilat janë në çdo ekran të aplikacionit.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/sr/impressum.html b/app/src/main/assets/impressum/sr/impressum.html index 830e1b170..c2cf11a48 100644 --- a/app/src/main/assets/impressum/sr/impressum.html +++ b/app/src/main/assets/impressum/sr/impressum.html @@ -105,33 +105,6 @@

      Licence i zasluge

      Upozorenje: Ova aplikacija vas ne štiti od infekcije virusom COVID-19.

      Ova aplikacija je namenjena za praćenje kontakata i za upozoravanje korisnika o mogućem kontaktu sa osobama koje su inficirane virusom COVID-19. Ona nije dijagnostička alatka. Pažljivo pročitajte uputstva na svakom ekranu aplikacije.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/ti/impressum.html b/app/src/main/assets/impressum/ti/impressum.html index b01d249a4..d16a6aef4 100644 --- a/app/src/main/assets/impressum/ti/impressum.html +++ b/app/src/main/assets/impressum/ti/impressum.html @@ -105,33 +105,6 @@

      ሕጋዊ ፍቓድ & ኣበርክቶ

      መጠንቀቕታ:- እዚ ኣፕ ካብ ሕማም ኮቪድ-19 ኣይከላኸለልኩምን እዩ።

      እቲ ኣፕ ንምክትታል ምቅርራብን የገልግል። ከምኡውን ንተጠቀምቲ ኣፕ ካብ ተኽእሎ ምልካፍ ብሕማም ድሕሪ ምስ ብኮቪድ-19 ዝሓመመ ሰብ ምርኻቦም የጠንቅቖም። ናይ ነጸርታ መሳርሒ ውን ኣይኮነን። ኣብ ነፍስወከፍ ስክሪን ናይ ኣፕ ዝርከቡ መምርሒታት ብግቡእ ኣንብቦብዎም።

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/assets/impressum/tr/impressum.html b/app/src/main/assets/impressum/tr/impressum.html index 4f63388ca..dbf9b6e8b 100644 --- a/app/src/main/assets/impressum/tr/impressum.html +++ b/app/src/main/assets/impressum/tr/impressum.html @@ -105,33 +105,6 @@

      Lisanslar & Krediler

      Uyarı: Bu uygulama sizi KOVİD-19 enfeksiyonundan korumaz.

      Uygulama, yakınlığı izlemek ve kullanıcıları KOVİD-19 ile enfekte olmuş bir kişiyle karşılaştıktan sonra olası bir enfeksiyona karşı uyarmak için kullanılır. Aynı zamanda bir teşhis aracı değildir. Lütfen uygulama ekranında bulunan talimatları dikkatlice okuyun.

      -
      -
      - - - - -
      -
      -

      Bundesamt für Gesundheit BAG

      -

      Schwarzenburgstrasse 157

      -

      3097 Liebefeld, Switzerland

      -
      -
      -
      -
      -
      -

      SwissCovid App Version {APPVERSION}

      -

      Release Date: {RELEASEDATE}

      -
      -
      - - - CE marking - -
      -
      -
      {BUILD}
      diff --git a/app/src/main/java/ch/admin/bag/dp3t/MainActivity.java b/app/src/main/java/ch/admin/bag/dp3t/MainActivity.java deleted file mode 100644 index 8ef66e42a..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/MainActivity.java +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t; - -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.ViewModelProvider; - -import org.dpppt.android.sdk.DP3T; - -import ch.admin.bag.dp3t.home.model.TracingStatusInterface; -import ch.admin.bag.dp3t.inform.InformActivity; -import ch.admin.bag.dp3t.networking.ConfigWorker; -import ch.admin.bag.dp3t.onboarding.OnboardingActivity; -import ch.admin.bag.dp3t.reports.ReportsFragment; -import ch.admin.bag.dp3t.storage.SecureStorage; -import ch.admin.bag.dp3t.updateboarding.UpdateBoardingActivity; -import ch.admin.bag.dp3t.util.UrlUtil; -import ch.admin.bag.dp3t.viewmodel.TracingViewModel; -import ch.admin.bag.dp3t.whattodo.WtdPositiveTestFragment; - -import static ch.admin.bag.dp3t.inform.InformActivity.EXTRA_COVIDCODE; -import static ch.admin.bag.dp3t.updateboarding.UpdateBoardingActivity.UPDATE_BOARDING_VERSION; -import static ch.admin.bag.dp3t.util.NotificationUtil.ACTION_ACTIVATE_TRACING; - -public class MainActivity extends FragmentActivity { - - public static final String ACTION_EXPOSED_GOTO_REPORTS = "ACTION_EXPOSED_GOTO_REPORTS"; - public static final String ACTION_INFORMED_GOTO_REPORTS = "ACTION_INFORMED_GOTO_REPORTS"; - - private static final String STATE_CONSUMED_EXPOSED_INTENT = "STATE_CONSUMED_EXPOSED_INTENT"; - private static final String STATE_CONSUMED_COVIDCODE_INTENT = "STATE_CONSUMED_COVIDCODE_INTENT"; - private boolean consumedExposedIntent; - private boolean consumedCovidcodeIntent; - - private SecureStorage secureStorage; - private TracingViewModel tracingViewModel; - - private AlertDialog forceUpdateDialog; - - private final ActivityResultLauncher onAndUpdateBoardingLauncher = - registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> { - if (activityResult.getResultCode() == RESULT_OK) { - secureStorage.setLastShownUpdateBoardingVersion(UPDATE_BOARDING_VERSION); - secureStorage.setOnboardingCompleted(true); - showHomeFragment(); - } else { - finish(); - } - }); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - secureStorage = SecureStorage.getInstance(this); - - secureStorage.getForceUpdateLiveData().observe(this, forceUpdate -> { - forceUpdate = forceUpdate && secureStorage.getDoForceUpdate(); - if (forceUpdate && forceUpdateDialog == null) { - forceUpdateDialog = new AlertDialog.Builder(this, R.style.NextStep_AlertDialogStyle) - .setTitle(R.string.force_update_title) - .setMessage(R.string.force_update_text) - .setPositiveButton(R.string.playservices_update, null) - .setCancelable(false) - .create(); - forceUpdateDialog.setOnShowListener(dialog -> - forceUpdateDialog.getButton(DialogInterface.BUTTON_POSITIVE) - .setOnClickListener(v -> { - String packageName = getPackageName(); - UrlUtil.openUrl(MainActivity.this, "market://details?id=" + packageName); - })); - forceUpdateDialog.show(); - } else if (!forceUpdate && forceUpdateDialog != null) { - forceUpdateDialog.dismiss(); - forceUpdateDialog = null; - } - }); - - ConfigWorker.scheduleConfigWorkerIfOutdated(this); - - if (savedInstanceState == null) { - boolean onboardingCompleted = secureStorage.getOnboardingCompleted(); - int lastShownUpdateBoardingVersion = secureStorage.getLastShownUpdateBoardingVersion(); - if (!onboardingCompleted) { - onAndUpdateBoardingLauncher.launch(new Intent(this, OnboardingActivity.class)); - } else if (lastShownUpdateBoardingVersion < UPDATE_BOARDING_VERSION) { - onAndUpdateBoardingLauncher.launch(new Intent(this, UpdateBoardingActivity.class)); - } else { - showHomeFragment(); - } - } else { - consumedExposedIntent = savedInstanceState.getBoolean(STATE_CONSUMED_EXPOSED_INTENT); - consumedCovidcodeIntent = savedInstanceState.getBoolean(STATE_CONSUMED_COVIDCODE_INTENT); - } - - tracingViewModel = new ViewModelProvider(this).get(TracingViewModel.class); - tracingViewModel.sync(); - - checkRedirectionIntents(); - } - - public void checkRedirectionIntents() { - checkIntentForActions(); - - if (!consumedExposedIntent) { - boolean isOpenLeitfadenPending = secureStorage.isOpenLeitfadenPending(); - boolean isExposed = tracingViewModel.getTracingStatusInterface().wasContactReportedAsExposed(); - if (isOpenLeitfadenPending && isExposed) { - gotoReportsFragment(); - } - } - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean(STATE_CONSUMED_EXPOSED_INTENT, consumedExposedIntent); - outState.putBoolean(STATE_CONSUMED_COVIDCODE_INTENT, consumedCovidcodeIntent); - } - - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - consumedCovidcodeIntent = false; - } - - @Override - protected void onStart() { - super.onStart(); - secureStorage.setAppOpenAfterNotificationPending(false); - } - - @Override - protected void onResume() { - super.onResume(); - if (secureStorage.getOnboardingCompleted()) checkValidCovidcodeIntent(); - } - - private void checkIntentForActions() { - Intent intent = getIntent(); - String intentAction = intent.getAction(); - boolean launchedFromHistory = (intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0; - if (ACTION_INFORMED_GOTO_REPORTS.equals(intentAction) && !launchedFromHistory) { - secureStorage.setLeitfadenOpenPending(false); - secureStorage.setReportsHeaderAnimationPending(false); - gotoReportsFragment(); - intent.setAction(null); - setIntent(intent); - } else if (ACTION_EXPOSED_GOTO_REPORTS.equals(intentAction) && !launchedFromHistory && !consumedExposedIntent) { - consumedExposedIntent = true; - intent.setAction(null); - setIntent(intent); - if (tracingViewModel.getTracingStatusInterface().wasContactReportedAsExposed()) { - gotoReportsFragment(); - } - } else if (ACTION_ACTIVATE_TRACING.equals(intentAction)) { - tracingViewModel.enableTracing(this, () -> {}, e -> {}, () -> {}); - } - } - - private void checkValidCovidcodeIntent() { - boolean launchedFromHistory = (getIntent().getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0; - TracingStatusInterface tracingStatus = tracingViewModel.getAppStatusLiveData().getValue(); - if (getIntent().getData() == null || launchedFromHistory || tracingStatus == null || tracingStatus.isReportedAsInfected() || - consumedCovidcodeIntent) { - return; - } - Uri uri = Uri.parse(getIntent().getData().toString()); - if (!uri.getHost().equals("cc.admin.ch")) return; - if (!uri.getPath().equals("") && !uri.getPath().equals("/")) return; - String covidCode = uri.getFragment(); - if (covidCode == null || covidCode.length() != 12) return; - startInformFlow(covidCode); - consumedCovidcodeIntent = true; - } - - private void startInformFlow(String covidCode) { - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_fragment_container, WtdPositiveTestFragment.newInstance()) - .addToBackStack(WtdPositiveTestFragment.class.getCanonicalName()) - .commit(); - Intent intent = new Intent(this, InformActivity.class); - intent.putExtra(EXTRA_COVIDCODE, covidCode); - startActivity(intent); - } - - private void showHomeFragment() { - getSupportFragmentManager() - .beginTransaction() - .add(R.id.main_fragment_container, TabbarHostFragment.newInstance()) - .commit(); - } - - private void gotoReportsFragment() { - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.main_fragment_container, ReportsFragment.newInstance()) - .addToBackStack(ReportsFragment.class.getCanonicalName()) - .commit(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - DP3T.onActivityResult(this, requestCode, resultCode, data); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt b/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt new file mode 100644 index 000000000..d9551cd2e --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t + +import android.content.* +import android.net.Uri +import android.os.Bundle +import androidx.activity.result.ActivityResult +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import ch.admin.bag.dp3t.checkin.CheckinOverviewFragment +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.checkinflow.CheckInFragment +import ch.admin.bag.dp3t.checkin.checkinflow.CheckOutFragment +import ch.admin.bag.dp3t.checkin.models.CheckInState +import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState +import ch.admin.bag.dp3t.checkin.networking.CrowdNotifierKeyLoadWorker +import ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper +import ch.admin.bag.dp3t.checkin.utils.ErrorDialog +import ch.admin.bag.dp3t.checkin.utils.NotificationHelper +import ch.admin.bag.dp3t.inform.InformActivity +import ch.admin.bag.dp3t.networking.ConfigWorker.Companion.scheduleConfigWorkerIfOutdated +import ch.admin.bag.dp3t.onboarding.OnboardingActivityArgs +import ch.admin.bag.dp3t.onboarding.OnboardingActivityResultContract +import ch.admin.bag.dp3t.onboarding.OnboardingSlidePageAdapter.Companion.UPDATE_BOARDING_VERSION +import ch.admin.bag.dp3t.onboarding.OnboardingType +import ch.admin.bag.dp3t.reports.ReportsFragment +import ch.admin.bag.dp3t.reports.ReportsOverviewFragment +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.util.NotificationUtil +import ch.admin.bag.dp3t.util.UrlUtil +import ch.admin.bag.dp3t.viewmodel.TracingViewModel +import ch.admin.bag.dp3t.whattodo.WtdPositiveTestFragment +import com.google.android.gms.instantapps.InstantApps +import org.crowdnotifier.android.sdk.CrowdNotifier +import org.crowdnotifier.android.sdk.utils.QrUtils.* +import org.dpppt.android.sdk.DP3T +import java.nio.charset.StandardCharsets + + +class MainActivity : FragmentActivity() { + + + companion object { + const val ACTION_EXPOSED_GOTO_REPORTS = "ACTION_EXPOSED_GOTO_REPORTS" + const val ACTION_INFORMED_GOTO_REPORTS = "ACTION_INFORMED_GOTO_REPORTS" + private const val KEY_IS_INTENT_CONSUMED = "KEY_IS_INTENT_CONSUMED" + } + + private var isIntentConsumed = false + private val secureStorage: SecureStorage by lazy { SecureStorage.getInstance(this) } + private val tracingViewModel: TracingViewModel by viewModels() + private val crowdNotifierViewModel: CrowdNotifierViewModel by viewModels() + + private val onboardingLauncher = registerForActivityResult(OnboardingActivityResultContract()) { + if (it != null) { + onOnboardingFinished(it.onboardingType, it.activityResult, it.instantAppUrl) + } else { + finish() + } + } + + private val autoCheckoutBroadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + showHomeFragment() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + secureStorage.forceUpdateLiveData.observe(this, { + + val forceUpdate = it && secureStorage.doForceUpdate + + if (forceUpdate) { + val forceUpdateDialog = AlertDialog.Builder(this, R.style.NextStep_AlertDialogStyle) + .setTitle(R.string.force_update_title) + .setMessage(R.string.force_update_text) + .setPositiveButton(R.string.playservices_update, null) + .setCancelable(false) + .create() + forceUpdateDialog.setOnShowListener { + forceUpdateDialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + UrlUtil.openUrl(this@MainActivity, "market://details?id=$packageName") + } + } + forceUpdateDialog.show() + } + }) + scheduleConfigWorkerIfOutdated(this) + CrowdNotifierKeyLoadWorker.startKeyLoadWorker(this) + CrowdNotifierKeyLoadWorker.cleanUpOldData(this) + if (savedInstanceState == null) { + val onboardingCompleted = secureStorage.onboardingCompleted + val lastShownUpdateBoardingVersion = secureStorage.lastShownUpdateBoardingVersion + val instantAppQrCodeUrl = checkForInstantAppUrl() + + val onboardingType = when { + instantAppQrCodeUrl != null -> OnboardingType.INSTANT_PART + !onboardingCompleted -> OnboardingType.NORMAL + lastShownUpdateBoardingVersion < UPDATE_BOARDING_VERSION -> OnboardingType.UPDATE_BOARDING + else -> null + } + + if (onboardingType == null) { + showHomeFragment() + } else { + launchOnboarding(onboardingType, instantAppQrCodeUrl) + } + } else { + isIntentConsumed = savedInstanceState.getBoolean(KEY_IS_INTENT_CONSUMED) + } + tracingViewModel.sync() + } + + fun launchOnboarding(onboardingType: OnboardingType, instantAppQrCodeUrl: String? = null) { + onboardingLauncher.launch(OnboardingActivityArgs(onboardingType, instantAppQrCodeUrl)) + } + + private fun onOnboardingFinished(onboardingType: OnboardingType, activityResult: ActivityResult, qrCodeUrl: String? = null) { + + if (activityResult.resultCode == RESULT_OK) { + secureStorage.lastShownUpdateBoardingVersion = UPDATE_BOARDING_VERSION + secureStorage.onboardingCompleted = true + if (onboardingType == OnboardingType.INSTANT_PART) { + secureStorage.onlyPartialOnboardingCompleted = true + qrCodeUrl?.let { checkIn(it) } + } else { + secureStorage.onlyPartialOnboardingCompleted = false + } + showHomeFragment() + } else { + finish() + } + } + + private fun checkForInstantAppUrl(): String? { + val pmc = InstantApps.getPackageManagerCompat(this) + val instantAppCookie = pmc.instantAppCookie + if (instantAppCookie != null && instantAppCookie.isNotEmpty()) { + // If there is an url in the instant app cookies, retun it and reset it to null + val url = String(instantAppCookie, StandardCharsets.UTF_8) + pmc.instantAppCookie = null + return url + } + return null + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(KEY_IS_INTENT_CONSUMED, isIntentConsumed) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + isIntentConsumed = false + } + + override fun onStart() { + super.onStart() + secureStorage.appOpenAfterNotificationPending = false + CrowdNotifierReminderHelper.autoCheckoutIfNecessary(this, crowdNotifierViewModel.checkInState) + } + + override fun onResume() { + super.onResume() + if (secureStorage.onboardingCompleted) checkIntentForActions() + LocalBroadcastManager.getInstance(this) + .registerReceiver(autoCheckoutBroadcastReceiver, IntentFilter(CrowdNotifierReminderHelper.ACTION_DID_AUTO_CHECKOUT)) + } + + private fun checkIntentForActions() { + val intent = intent + val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0 + if (!launchedFromHistory && !isIntentConsumed) { + isIntentConsumed = true + handleCustomIntents() + } + } + + private fun handleCustomIntents() { + val intentAction = intent.action + val isCheckedIn = crowdNotifierViewModel.isCheckedIn.value ?: false + val hasCheckinExposures = crowdNotifierViewModel.exposures.value?.isNotEmpty() == true + if (ACTION_INFORMED_GOTO_REPORTS == intentAction) { + secureStorage.setLeitfadenOpenPending(false) + secureStorage.isReportsHeaderAnimationPending = false + showReportsFragment() + } else if (ACTION_EXPOSED_GOTO_REPORTS == intentAction) { + if (tracingViewModel.tracingStatusInterface.wasContactReportedAsExposed() || hasCheckinExposures) { + showReportsFragment() + } + } else if (NotificationUtil.ACTION_ACTIVATE_TRACING == intentAction) { + tracingViewModel.enableTracing(this, {}, { }) {} + } else if ((NotificationHelper.ACTION_CROWDNOTIFIER_REMINDER_NOTIFICATION == intentAction || NotificationHelper.ACTION_ONGOING_NOTIFICATION == intentAction) && isCheckedIn) { + showFragmentWithoutAnimation(CheckinOverviewFragment.newInstance()) + } else if (NotificationHelper.ACTION_CHECK_OUT_NOW == intentAction && isCheckedIn) { + showCheckOutFragment() + } else if (intent.data != null) { + checkValidCovidcodeIntent() + checkValidCheckInIntent() + } + } + + private fun checkValidCheckInIntent() { + val qrCodeData = intent.dataString ?: return + + if (Uri.parse(qrCodeData).host != BuildConfig.ENTRY_QR_CODE_HOST) return + try { + val venueInfo = CrowdNotifier.getVenueInfo(qrCodeData, BuildConfig.ENTRY_QR_CODE_HOST) + if (crowdNotifierViewModel.isCheckedIn.value == true) { + if (crowdNotifierViewModel.checkInState.venueInfo == venueInfo) { + showCheckOutFragment() + } else { + ErrorDialog(this, CrowdNotifierErrorState.ALREADY_CHECKED_IN).show() + } + } else { + crowdNotifierViewModel.checkInState = CheckInState( + false, venueInfo, System.currentTimeMillis(), + System.currentTimeMillis(), 0 + ) + showFragmentWithoutAnimation(CheckInFragment.newInstance(false)) + } + } catch (e: QRException) { + handleInvalidQRCodeExceptions(e) + } + } + + private fun checkIn(qrCodeUrl: String) { + try { + val venueInfo = CrowdNotifier.getVenueInfo(qrCodeUrl, BuildConfig.ENTRY_QR_CODE_HOST) + if (crowdNotifierViewModel.isCheckedIn.value == true) { + ErrorDialog(this, CrowdNotifierErrorState.ALREADY_CHECKED_IN).show() + } else { + crowdNotifierViewModel.performCheckinAndSetReminders(venueInfo, System.currentTimeMillis(), 0) + } + } catch (e: QRException) { + handleInvalidQRCodeExceptions(e) + } + } + + private fun handleInvalidQRCodeExceptions(e: QRException) { + if (e is InvalidQRCodeVersionException) { + ErrorDialog(this, CrowdNotifierErrorState.UPDATE_REQUIRED).show() + } else if (e is NotYetValidException) { + ErrorDialog(this, CrowdNotifierErrorState.QR_CODE_NOT_YET_VALID).show() + } else if (e is NotValidAnymoreException) { + ErrorDialog(this, CrowdNotifierErrorState.QR_CODE_NOT_VALID_ANYMORE).show() + } else { + ErrorDialog(this, CrowdNotifierErrorState.NO_VALID_QR_CODE).show() + } + } + + private fun checkValidCovidcodeIntent() { + val tracingStatus = tracingViewModel.appStatusLiveData.value + if (tracingStatus == null || tracingStatus.isReportedAsInfected) { + return + } + val uri = Uri.parse(intent.data.toString()) + if (uri.host != "cc.admin.ch") return + if (uri.path != "" && uri.path != "/") return + val covidCode = uri.fragment + if (covidCode == null || covidCode.length != 12) return + startInformFlow(covidCode) + } + + private fun startInformFlow(covidCode: String) { + showFragmentWithoutAnimation(WtdPositiveTestFragment.newInstance()) + val intent = Intent(this, InformActivity::class.java) + intent.putExtra(InformActivity.EXTRA_COVIDCODE, covidCode) + startActivity(intent) + } + + private fun showHomeFragment() { + supportFragmentManager.beginTransaction() + .add(R.id.main_fragment_container, TabbarHostFragment.newInstance()) + .commit() + } + + private fun showReportsFragment() { + val checkinReports = crowdNotifierViewModel.exposures.value?.size ?: 0 + val tracingReports = tracingViewModel.appStatusLiveData.value?.exposureDays?.size ?: 0 + val isReportedPositive = tracingViewModel.tracingStatusInterface.isReportedAsInfected + val reportsFragment: Fragment = + if ((checkinReports > 0 && tracingReports > 0 || checkinReports > 1) && !isReportedPositive) { + ReportsOverviewFragment.newInstance() + } else { + ReportsFragment.newInstance(null) + } + showFragmentWithoutAnimation(reportsFragment) + } + + private fun showCheckOutFragment() { + supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.modal_slide_enter, R.anim.modal_slide_exit, R.anim.modal_pop_enter, R.anim.modal_pop_exit) + .replace(R.id.main_fragment_container, CheckOutFragment.newInstance()) + .addToBackStack(CheckOutFragment::class.java.canonicalName) + .commit() + } + + private fun showFragmentWithoutAnimation(fragment: Fragment) { + supportFragmentManager.beginTransaction() + .replace(R.id.main_fragment_container, fragment) + .addToBackStack(fragment::class.java.canonicalName) + .commit() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + DP3T.onActivityResult(this, requestCode, resultCode, data) + } + + override fun onPause() { + LocalBroadcastManager.getInstance(this).unregisterReceiver(autoCheckoutBroadcastReceiver) + super.onPause() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/TabbarHostFragment.java b/app/src/main/java/ch/admin/bag/dp3t/TabbarHostFragment.java index 34938a7ee..b8cdc100e 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/TabbarHostFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/TabbarHostFragment.java @@ -25,6 +25,7 @@ import ch.admin.bag.dp3t.debug.DebugFragment; import ch.admin.bag.dp3t.home.HomeFragment; import ch.admin.bag.dp3t.html.HtmlFragment; +import ch.admin.bag.dp3t.infotab.InfoTabFragment; import ch.admin.bag.dp3t.stats.StatsFragment; import ch.admin.bag.dp3t.stats.StatsViewModel; import ch.admin.bag.dp3t.util.AssetUtil; @@ -112,7 +113,6 @@ private void setupBottomNavigationView() { lastSelectedTab = item.getItemId(); lastTabSwitch = System.currentTimeMillis(); - // Use a switch anyway, there may be more tabs in the future switch (item.getItemId()) { case R.id.bottom_nav_stats: getChildFragmentManager().beginTransaction() @@ -121,6 +121,11 @@ private void setupBottomNavigationView() { statsViewModel.loadStats(); break; + case R.id.bottom_nav_info: + getChildFragmentManager().beginTransaction() + .replace(R.id.tabs_fragment_container, InfoTabFragment.newInstance()) + .commit(); + break; default: getChildFragmentManager().beginTransaction() .replace(R.id.tabs_fragment_container, HomeFragment.newInstance()) diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/CheckinOverviewFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/CheckinOverviewFragment.kt new file mode 100644 index 000000000..d69868b98 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/CheckinOverviewFragment.kt @@ -0,0 +1,101 @@ +package ch.admin.bag.dp3t.checkin + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.checkinflow.CheckOutFragment +import ch.admin.bag.dp3t.checkin.checkinflow.QrCodeScannerFragment +import ch.admin.bag.dp3t.checkin.diary.DiaryFragment +import ch.admin.bag.dp3t.checkin.generateqrcode.EventsOverviewFragment +import ch.admin.bag.dp3t.checkin.generateqrcode.QRCodeViewModel +import ch.admin.bag.dp3t.databinding.FragmentCheckinOverviewBinding +import ch.admin.bag.dp3t.extensions.showFragment +import ch.admin.bag.dp3t.util.StringUtil +import ch.admin.bag.dp3t.viewmodel.TracingViewModel + +class CheckinOverviewFragment : Fragment() { + + companion object { + @JvmStatic + fun newInstance(): CheckinOverviewFragment { + return CheckinOverviewFragment() + } + } + + private val crowdNotifierViewModel: CrowdNotifierViewModel by activityViewModels() + private val tracingViewModel: TracingViewModel by activityViewModels() + private val qrCodeViewModel: QRCodeViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentCheckinOverviewBinding.inflate(inflater).apply { + checkinOverviewToolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } + + checkinOverviewDiary.setOnClickListener { authenticateAndShowDiary() } + + tracingViewModel.appStatusLiveData.observe(viewLifecycleOwner) { + checkinOverviewIsolation.isVisible = it.isReportedAsInfected + checkinOverviewScanQr.isVisible = !it.isReportedAsInfected + } + + crowdNotifierViewModel.isCheckedIn.observe(viewLifecycleOwner, { isCheckedIn -> + checkoutView.isVisible = isCheckedIn + checkinView.isVisible = !isCheckedIn + if (isCheckedIn) { + val venueInfo = crowdNotifierViewModel.checkInState.venueInfo + crowdNotifierViewModel.startCheckInTimer() + checkinTitle.text = venueInfo.description + } + }) + + checkinButton.setOnClickListener { showFragment(QrCodeScannerFragment.newInstance()) } + checkoutButton.setOnClickListener { showFragment(CheckOutFragment.newInstance(), modalAnimation = true) } + + crowdNotifierViewModel.timeSinceCheckIn.observe(viewLifecycleOwner) { duration -> + checkinTime.text = StringUtil.getShortDurationString(duration) + } + + qrCodeViewModel.generatedQrCodesLiveData.observe(viewLifecycleOwner) { + qrCodeGenerate.evemtsCardTitle.text = + if (it.isEmpty()) { + getString(R.string.events_card_title) + } else { + getString(R.string.events_card_title_events_not_empty) + } + } + + qrCodeGenerate.root.setOnClickListener { showFragment(EventsOverviewFragment.newInstance()) } + + }.root + } + + private fun authenticateAndShowDiary() { + val biometricState = BiometricManager.from(requireContext()) + .canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK) + if (biometricState == BiometricManager.BIOMETRIC_SUCCESS) { + val executor = ContextCompat.getMainExecutor(requireContext()) + val biometricPrompt = BiometricPrompt(requireActivity(), executor, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + showFragment(DiaryFragment.newInstance()) + } + }) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.authenticate_for_diary)) + .setAllowedAuthenticators( + BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK + ) + .build() + biometricPrompt.authenticate(promptInfo) + } else { + showFragment(DiaryFragment.newInstance()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/CrowdNotifierViewModel.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/CrowdNotifierViewModel.java new file mode 100644 index 000000000..6c0456e0b --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/CrowdNotifierViewModel.java @@ -0,0 +1,229 @@ +package ch.admin.bag.dp3t.checkin; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import java.util.Collections; +import java.util.List; + +import org.crowdnotifier.android.sdk.CrowdNotifier; +import org.crowdnotifier.android.sdk.model.ExposureEvent; +import org.crowdnotifier.android.sdk.model.VenueInfo; + +import ch.admin.bag.dp3t.checkin.networking.TraceKeysRepository; +import ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper; +import ch.admin.bag.dp3t.checkin.utils.NotificationHelper; +import ch.admin.bag.dp3t.extensions.VenueInfoExtensionsKt; +import ch.admin.bag.dp3t.storage.SecureStorage; +import ch.admin.bag.dp3t.checkin.models.CheckInState; +import ch.admin.bag.dp3t.util.DateUtils; + +import static ch.admin.bag.dp3t.checkin.networking.CrowdNotifierKeyLoadWorker.ACTION_NEW_TRACE_KEY_SYNC; +import static ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper.ACTION_DID_AUTO_CHECKOUT; + + +public class CrowdNotifierViewModel extends AndroidViewModel { + + private static final long MAX_DURATION_WITHOUT_SUCCESSFUL_DOWNLOAD = 24 * 60 * 60 * 1000L; + + private final MutableLiveData> exposures = new MutableLiveData<>(); + private final MutableLiveData timeSinceCheckIn = new MutableLiveData<>(0L); + private final MutableLiveData traceKeyLoadingState = new MutableLiveData<>(LoadingState.SUCCESS); + private final MutableLiveData hasTraceKeyDownloadError = new MutableLiveData<>(false); + + private final MutableLiveData isCheckedIn = new MutableLiveData<>(false); + private CheckInState checkInState; + + private SecureStorage storage; + private final Handler handler = new Handler(Looper.getMainLooper()); + private Runnable timeUpdateRunnable; + private final long CHECK_IN_TIME_UPDATE_INTERVAL = 1000; + private TraceKeysRepository traceKeysRepository = new TraceKeysRepository(getApplication()); + + private BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (ACTION_DID_AUTO_CHECKOUT.equals(intent.getAction())) { + setCheckInState(null); + } else if (ACTION_NEW_TRACE_KEY_SYNC.equals(intent.getAction())) { + refreshExposures(); + refreshTraceKeyLoadingError(); + } + } + }; + + public CrowdNotifierViewModel(@NonNull Application application) { + super(application); + refreshExposures(); + storage = SecureStorage.getInstance(getApplication()); + checkInState = storage.getCheckInState(); + updateCheckedIn(); + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(application); + localBroadcastManager.registerReceiver(broadcastReceiver, new IntentFilter(ACTION_DID_AUTO_CHECKOUT)); + localBroadcastManager.registerReceiver(broadcastReceiver, new IntentFilter(ACTION_NEW_TRACE_KEY_SYNC)); + refreshTraceKeyLoadingError(); + } + + public void startCheckInTimer() { + handler.removeCallbacks(timeUpdateRunnable); + timeUpdateRunnable = () -> { + if (checkInState != null) { + timeSinceCheckIn.setValue(System.currentTimeMillis() - checkInState.getCheckInTime()); + } else { + timeSinceCheckIn.setValue(0L); + } + handler.postDelayed(timeUpdateRunnable, CHECK_IN_TIME_UPDATE_INTERVAL); + }; + handler.postDelayed(timeUpdateRunnable, CHECK_IN_TIME_UPDATE_INTERVAL - + (System.currentTimeMillis() - checkInState.getCheckInTime() % CHECK_IN_TIME_UPDATE_INTERVAL)); + timeSinceCheckIn.setValue(System.currentTimeMillis() - checkInState.getCheckInTime()); + } + + public void setCheckInState(CheckInState checkInState) { + storage.setCheckInState(checkInState); + this.checkInState = checkInState; + updateCheckedIn(); + } + + public CheckInState getCheckInState() { + return checkInState; + } + + public void setCheckedIn(boolean checkedIn) { + if (checkInState != null) checkInState.setCheckedIn(checkedIn); + setCheckInState(checkInState); + } + + public LiveData> getExposures() { + return exposures; + } + + public LiveData getTimeSinceCheckIn() { + return timeSinceCheckIn; + } + + public LiveData getTraceKeyLoadingState() { + return traceKeyLoadingState; + } + + public LiveData hasTraceKeyLoadingError() { return hasTraceKeyDownloadError; } + + public LiveData isCheckedIn() { + return isCheckedIn; + } + + public void refreshTraceKeys() { + traceKeyLoadingState.setValue(LoadingState.LOADING); + traceKeysRepository.loadTraceKeysAsync(traceKeys -> { + if (traceKeys == null) { + traceKeyLoadingState.setValue(LoadingState.FAILURE); + } else { + SecureStorage.getInstance(getApplication()).setLastSuccessfulCheckinDownload(System.currentTimeMillis()); + CrowdNotifier.checkForMatches(traceKeys, getApplication()); + refreshExposures(); + traceKeyLoadingState.setValue(LoadingState.SUCCESS); + } + refreshTraceKeyLoadingError(); + }); + } + + private void refreshTraceKeyLoadingError() { + + if (storage.getLastSuccessfulCheckinDownload() <= System.currentTimeMillis() - MAX_DURATION_WITHOUT_SUCCESSFUL_DOWNLOAD) { + hasTraceKeyDownloadError.setValue(true); + } else { + hasTraceKeyDownloadError.setValue(false); + } + } + + private void updateCheckedIn() { + if (checkInState == null) { + isCheckedIn.setValue(false); + } else { + isCheckedIn.setValue(checkInState.isCheckedIn()); + } + } + + public void refreshExposures() { + List newExposures = CrowdNotifier.getExposureEvents(getApplication()); + Collections.sort(newExposures, (e1, e2) -> Long.compare(e2.getStartTime(), e1.getStartTime())); + exposures.setValue(newExposures); + } + + public void removeExposure(long exposureId) { + CrowdNotifier.removeExposure(getApplication(), exposureId); + refreshExposures(); + } + + public ExposureEvent getExposureWithId(long id) { + List exposureEvents = exposures.getValue(); + if (exposureEvents == null) return null; + for (ExposureEvent exposureEvent : exposureEvents) { + if (exposureEvent.getId() == id) { + return exposureEvent; + } + } + return null; + } + + public ExposureEvent getLatestExposure() { + List exposureEvents = exposures.getValue(); + if (exposureEvents == null || exposureEvents.isEmpty()) return null; + ExposureEvent latestExposureEvent = exposureEvents.get(0); + for (ExposureEvent exposureEvent : exposureEvents) { + if (exposureEvent.getEndTime() > latestExposureEvent.getEndTime()) { + latestExposureEvent = exposureEvent; + } + } + return latestExposureEvent; + } + + public long getDaysSinceExposure() { + ExposureEvent latestExposure = getLatestExposure(); + if (latestExposure == null) return -1; + return Math.max(0, DateUtils.getDaysDiff(getLatestExposure().getEndTime())); + } + + public long getSelectedReminderDelay() { + return checkInState.getSelectedReminderDelay(); + } + + public void setSelectedReminderDelay(long selectedReminderDelay) { + this.checkInState.setSelectedReminderDelay(selectedReminderDelay); + storage.setCheckInState(checkInState); + } + + public void performCheckinAndSetReminders(VenueInfo venueInfo, long checkinTime, long selectedReminderDelay) { + long currentTime = System.currentTimeMillis(); + setCheckInState(new CheckInState(true, venueInfo, checkinTime, checkinTime, selectedReminderDelay)); + startCheckInTimer(); + NotificationHelper.getInstance(getApplication()).startOngoingNotification(checkinTime, venueInfo); + CrowdNotifierReminderHelper + .setCheckoutWarning(checkinTime, VenueInfoExtensionsKt.getCheckoutWarningDelay(venueInfo), getApplication()); + CrowdNotifierReminderHelper + .setAutoCheckOut(checkinTime, VenueInfoExtensionsKt.getAutoCheckoutDelay(venueInfo), getApplication()); + CrowdNotifierReminderHelper.setReminder(currentTime + selectedReminderDelay, getApplication()); + } + + @Override + public void onCleared() { + super.onCleared(); + LocalBroadcastManager.getInstance(getApplication()).unregisterReceiver(broadcastReceiver); + handler.removeCallbacks(timeUpdateRunnable); + } + + public enum LoadingState { + LOADING, SUCCESS, FAILURE + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/EditCheckinBaseFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/EditCheckinBaseFragment.kt new file mode 100644 index 000000000..183083e98 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/EditCheckinBaseFragment.kt @@ -0,0 +1,80 @@ +package ch.admin.bag.dp3t.checkin + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.models.CheckinInfo +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.databinding.FragmentCheckOutAndEditBinding +import ch.admin.bag.dp3t.extensions.getSwissCovidLocationData +import ch.admin.bag.dp3t.util.StringUtil + +abstract class EditCheckinBaseFragment : Fragment() { + + abstract val checkinInfo: CheckinInfo + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentCheckOutAndEditBinding.inflate(inflater, container, false).apply { + checkoutTitle.text = checkinInfo.venueInfo.title + + toolbarCancelButton.setOnClickListener { requireActivity().supportFragmentManager.popBackStack() } + + checkoutTimeArrival.setDateTime(checkinInfo.checkInTime) + checkoutTimeArrival.setOnDateTimeChangedListener { + checkinInfo.checkInTime = checkoutTimeArrival.getSelectedUnixTimestamp() + } + + checkoutTimeDeparture.setDateTime(checkinInfo.checkOutTime) + checkoutTimeDeparture.setOnDateTimeChangedListener { + checkinInfo.checkOutTime = checkoutTimeDeparture.getSelectedUnixTimestamp() + } + }.root + } + + fun performSave() { + val context = requireContext() + + if (checkinInfo.checkInTime > checkinInfo.checkOutTime) { + showSavingNotPossibleDialog(getString(R.string.checkout_inverse_time_alert_description), context) + return + } + + val hasOverlapWithOtherCheckin = checkForOverlap(checkinInfo, context) + if (hasOverlapWithOtherCheckin) { + showSavingNotPossibleDialog(getString(R.string.checkout_overlapping_alert_description), context) + return + } + + val checkinDuration = checkinInfo.checkOutTime - checkinInfo.checkInTime + val maxCheckinTime = checkinInfo.venueInfo.getSwissCovidLocationData().automaticCheckoutDelaylMs + if (checkinDuration > maxCheckinTime) { + val maxDurationString = StringUtil.getShortDurationStringWithUnits(maxCheckinTime, context) + val dialogText = context.getString(R.string.checkout_too_long_alert_text).replace("{DURATION}", maxDurationString) + showSavingNotPossibleDialog(dialogText, context) + return + } + + saveEntry() + } + + abstract fun saveEntry() + + private fun checkForOverlap(diaryEntry: CheckinInfo, context: Context): Boolean { + val otherCheckins = DiaryStorage.getInstance(context).entries.filter { it.id != diaryEntry.id } + return otherCheckins.any { it.checkOutTime > diaryEntry.checkInTime && diaryEntry.checkOutTime > it.checkInTime } + } + + private fun showSavingNotPossibleDialog(message: String, context: Context) { + AlertDialog.Builder(context, R.style.NextStep_AlertDialogStyle) + .setTitle(R.string.checkout_overlapping_alert_title) + .setMessage(message) + .setNegativeButton(R.string.android_button_ok) { dialog, _ -> dialog.dismiss() } + .show() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CameraPermissionExplanationDialog.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CameraPermissionExplanationDialog.java new file mode 100644 index 000000000..87f754696 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CameraPermissionExplanationDialog.java @@ -0,0 +1,43 @@ +package ch.admin.bag.dp3t.checkin.checkinflow; + +import android.content.Context; +import android.graphics.Paint; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import ch.admin.bag.dp3t.R; + +public class CameraPermissionExplanationDialog extends AlertDialog { + + private View.OnClickListener grantCameraAccessClickListener; + + public CameraPermissionExplanationDialog(@NonNull Context context) { + super(context); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.dialog_camera_permission_explanation); + getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + getWindow().setBackgroundDrawableResource(R.drawable.dialog_background); + + TextView grantCameraAccessButton = findViewById(R.id.camera_permission_dialog_ok_button); + grantCameraAccessButton.setPaintFlags(grantCameraAccessButton.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + + grantCameraAccessButton.setOnClickListener(v -> { + dismiss(); + if (grantCameraAccessClickListener != null) grantCameraAccessClickListener.onClick(v); + }); + findViewById(R.id.camera_permission_dialog_close_button).setOnClickListener(v -> cancel()); + } + + public void setGrantCameraAccessClickListener(View.OnClickListener listener) { + this.grantCameraAccessClickListener = listener; + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckInFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckInFragment.kt new file mode 100644 index 000000000..614ab4d0e --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckInFragment.kt @@ -0,0 +1,226 @@ +package ch.admin.bag.dp3t.checkin.checkinflow + +import android.app.TimePickerDialog +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.MutableLiveData +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CheckinOverviewFragment +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.generateqrcode.EventsOverviewFragment +import ch.admin.bag.dp3t.checkin.models.ReminderOption +import ch.admin.bag.dp3t.databinding.DialogReminderDurationBinding +import ch.admin.bag.dp3t.databinding.FragmentCheckInBinding +import ch.admin.bag.dp3t.extensions.getReminderDelayOptions +import ch.admin.bag.dp3t.extensions.getSwissCovidLocationData +import ch.admin.bag.dp3t.util.StringUtil +import ch.admin.bag.dp3t.util.StringUtil.toHoursString +import ch.admin.bag.dp3t.util.StringUtil.toMinutesString +import ch.admin.bag.dp3t.util.UiUtils +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.button.MaterialButton +import com.shawnlin.numberpicker.NumberPicker +import java.time.Duration +import java.util.* + +private const val ARG_IS_SELF_CHECKIN = "ARG_IS_SELF_CHECKIN" + +class CheckInFragment : Fragment() { + + companion object { + val TAG = CheckInFragment::class.java.canonicalName + + @JvmStatic + fun newInstance(isSelfCheckin: Boolean = false) = CheckInFragment().apply { + arguments = bundleOf(ARG_IS_SELF_CHECKIN to isSelfCheckin) + } + + } + + private val viewModel: CrowdNotifierViewModel by activityViewModels() + + private val maxCheckinDuration: Duration + get() = + viewModel.checkInState?.venueInfo?.getSwissCovidLocationData()?.automaticCheckoutDelaylMs?.let { Duration.ofMillis(it) } + ?: Duration.ofHours(24) + + private var selectedCheckinTime = MutableLiveData(System.currentTimeMillis()) + + private var selectedReminderButton: MaterialButton? = null + private lateinit var customReminderButton: MaterialButton + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + checkIfAutoCheckoutHappened() + } + + override fun onStart() { + super.onStart() + checkIfAutoCheckoutHappened() + } + + private fun checkIfAutoCheckoutHappened() { + if (viewModel.checkInState == null) { + popBackToHomeFragment() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentCheckInBinding.inflate(inflater).apply { + val venueInfo = viewModel.checkInState?.venueInfo ?: return root + + titleTextview.text = venueInfo.title + checkInButton.setOnClickListener { + viewModel.performCheckinAndSetReminders(venueInfo, selectedCheckinTime.value!!, viewModel.selectedReminderDelay) + popBackToHomeFragment() + } + + selectedCheckinTime.observe(viewLifecycleOwner) { selectedTime -> + val formattedTime = StringUtil.getHourMinuteTimeString(selectedTime, ":") + checkinTime.text = getString(R.string.date_today) + ", " + formattedTime + } + checkinTime.setOnClickListener { + selectCheckinTime() + } + + reminderToggleGroup.removeAllViews() + val reminderOptions = venueInfo.getReminderDelayOptions(requireContext()).toMutableList() + reminderOptions.add(0, ReminderOption(0L)) + + for (option in reminderOptions) { + val toggleButton = + MaterialButton(ContextThemeWrapper(requireContext(), R.style.NextStep_ToggleButton), null, 0) + toggleButton.text = option.getDisplayString(requireContext()) + reminderToggleGroup.addView(toggleButton, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) + if (viewModel.selectedReminderDelay == option.delayMillis) { + reminderToggleGroup.check(toggleButton.id) + selectedReminderButton = toggleButton + } + toggleButton.addOnCheckedChangeListener { button, isChecked -> + if (isChecked) { + viewModel.selectedReminderDelay = option.delayMillis + selectedReminderButton = button + } + } + } + + customReminderButton = + MaterialButton(ContextThemeWrapper(requireContext(), R.style.NextStep_ToggleButton), null, 0) + customReminderButton.apply { + icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_stopwatch) + iconTint = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.anthracite)) + iconTintMode = PorterDuff.Mode.SRC_IN + iconSize = UiUtils.dpToPx(resources, 16) + iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START + iconPadding = 0 + } + reminderToggleGroup.addView(customReminderButton, LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) + customReminderButton.addOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + showCustomReminderDialog() + } else { + invalidateCustomReminderDelayButtonLabel(isChecked) + } + } + + toolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } + cancelButton.setOnClickListener { + requireActivity().supportFragmentManager.popBackStack(EventsOverviewFragment::class.java.canonicalName, 0) + } + selfCheckinToolbar.isVisible = requireArguments().getBoolean(ARG_IS_SELF_CHECKIN) + toolbar.isVisible = !requireArguments().getBoolean(ARG_IS_SELF_CHECKIN) + }.root + } + + private fun selectCheckinTime() { + val cal = Calendar.getInstance() + cal.timeInMillis = selectedCheckinTime.value!! + TimePickerDialog( + requireContext(), + R.style.NextStep_AlertDialogStyle, + { _, hourOfDay, minute -> + cal.set(Calendar.HOUR_OF_DAY, hourOfDay) + cal.set(Calendar.MINUTE, minute) + selectedCheckinTime.value = cal.timeInMillis + .coerceAtLeast(System.currentTimeMillis() - maxCheckinDuration.toMillis()) + .coerceAtMost(System.currentTimeMillis()) + }, + cal.get(Calendar.HOUR_OF_DAY), + cal.get(Calendar.MINUTE), + true + ).show() + } + + private fun showCustomReminderDialog() { + BottomSheetDialog(requireContext()).apply { + setContentView(DialogReminderDurationBinding.inflate(layoutInflater, null, false).apply { + dialogCancel.setOnClickListener { + cancel() + } + dialogDone.setOnClickListener { + viewModel.selectedReminderDelay = + Duration.ofHours(dialogHourPicker.value.toLong()).plusMinutes(dialogMinutePicker.value.toLong()).toMillis() + selectedReminderButton = customReminderButton + invalidateCustomReminderDelayButtonLabel(true) + dismiss() + } + + dialogHourPicker.apply { + minValue = 0 + maxValue = maxCheckinDuration.toHours().toInt() - 1 + wrapSelectorWheel = false + formatter = NumberPicker.Formatter { it.toLong().toHoursString(context) } + + value = (viewModel.selectedReminderDelay / 1000L / 60L / 60L).toInt() + .coerceAtMost(dialogHourPicker.maxValue) + } + dialogMinutePicker.apply { + minValue = 0 + maxValue = 59 + wrapSelectorWheel = true + formatter = NumberPicker.Formatter { it.toLong().toMinutesString(context) } + + value = (viewModel.selectedReminderDelay / 1000L / 60L % 60L).toInt() + } + }.root) + setOnCancelListener { + // select previous option + selectedReminderButton?.isChecked = true + } + show() + } + } + + private fun invalidateCustomReminderDelayButtonLabel(isChecked: Boolean) { + if (isChecked) { + customReminderButton.text = + StringUtil.getShortDurationStringWithUnits(viewModel.selectedReminderDelay, requireContext()) + customReminderButton.icon = null + } else { + customReminderButton.text = null + customReminderButton.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_stopwatch) + } + } + + private fun popBackToHomeFragment() { + if (requireArguments().getBoolean(ARG_IS_SELF_CHECKIN)) { + requireActivity().supportFragmentManager.popBackStack(CheckinOverviewFragment::class.java.canonicalName, 0) + } else { + requireActivity().supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckOutFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckOutFragment.kt new file mode 100644 index 000000000..a2e9828f4 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckOutFragment.kt @@ -0,0 +1,82 @@ +package ch.admin.bag.dp3t.checkin.checkinflow + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.EditCheckinBaseFragment +import ch.admin.bag.dp3t.checkin.models.CheckInState +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.checkin.models.CheckinInfo +import ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper +import ch.admin.bag.dp3t.checkin.utils.NotificationHelper +import ch.admin.bag.dp3t.databinding.FragmentCheckOutAndEditBinding +import org.crowdnotifier.android.sdk.CrowdNotifier +import org.crowdnotifier.android.sdk.model.VenueInfo + +class CheckOutFragment : EditCheckinBaseFragment() { + + companion object { + @JvmStatic + fun newInstance() = CheckOutFragment() + } + + private val viewModel: CrowdNotifierViewModel by activityViewModels() + + private lateinit var venueInfo: VenueInfo + private lateinit var checkInState: CheckInState + + override val checkinInfo: CheckinInfo + get() = checkInState + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + checkIfAutoCheckoutHappened() + checkInState = viewModel.checkInState?.copy(checkOutTime = System.currentTimeMillis()) ?: return + venueInfo = checkInState.venueInfo + } + + override fun onStart() { + super.onStart() + checkIfAutoCheckoutHappened() + } + + private fun checkIfAutoCheckoutHappened() { + if (viewModel.checkInState == null) { + popBackToHomeFragment() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + FragmentCheckOutAndEditBinding.bind(view).apply { + toolbarDoneButton.isVisible = false + + checkoutPrimaryButton.setText(R.string.checkout_button_title) + checkoutPrimaryButton.setOnClickListener { performSave() } + } + } + + override fun saveEntry() { + CrowdNotifierReminderHelper.removeAllReminders(context) + + val checkIn = checkInState.checkInTime + val checkOut = checkInState.checkOutTime + val id = CrowdNotifier.addCheckIn(checkIn, checkOut, venueInfo, context) + DiaryStorage.getInstance(context).addEntry(DiaryEntry(id, checkIn, checkOut, venueInfo)) + viewModel.checkInState = null + + val notificationHelper = NotificationHelper.getInstance(context) + notificationHelper.stopOngoingNotification() + notificationHelper.removeReminderNotification() + popBackToHomeFragment() + } + + private fun popBackToHomeFragment() { + requireActivity().supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/QrCodeAnalyzer.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/QrCodeAnalyzer.java new file mode 100644 index 000000000..99ff3ead3 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/QrCodeAnalyzer.java @@ -0,0 +1,93 @@ +package ch.admin.bag.dp3t.checkin.checkinflow; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.camera.core.ImageAnalysis; +import androidx.camera.core.ImageProxy; + +import java.nio.ByteBuffer; + +import com.google.zxing.*; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.multi.qrcode.QRCodeMultiReader; + +public class QrCodeAnalyzer implements ImageAnalysis.Analyzer { + + private final static String TAG = QrCodeAnalyzer.class.getCanonicalName(); + + private Listener listener; + + public QrCodeAnalyzer(Listener listener) { + this.listener = listener; + } + + @Override + public void analyze(@NonNull ImageProxy image) { + + int width = image.getWidth(); + int height = image.getHeight(); + int ySize = width * height; + + byte[] imageData = new byte[ySize]; + + ByteBuffer yBuffer = image.getPlanes()[0].getBuffer(); + + int rowStride = image.getPlanes()[0].getRowStride(); + int pos = 0; + + if (rowStride == width) { // likely + yBuffer.get(imageData, 0, ySize); + pos += ySize; + } else { + int yBufferPos = -rowStride; // not an actual position + for (; pos < ySize; pos += width) { + yBufferPos += rowStride; + yBuffer.position(yBufferPos); + yBuffer.get(imageData, pos, width); + } + } + + PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource( + imageData, + image.getWidth(), image.getHeight(), + 0, 0, + image.getWidth(), image.getHeight(), + false + ); + + BinaryBitmap binaryBitmap = new BinaryBitmap(new HybridBinarizer(source)); + + try { + Result result = new QRCodeMultiReader().decode(binaryBitmap); + checkQrCode(result); + } + // Catch all kinds of dubious exceptions that zxing throws + catch (FormatException e) { + Log.w(TAG, "Caught FormatException"); + } catch (ChecksumException e) { + Log.w(TAG, "Caught ChecksumException"); + } catch (NotFoundException e) { + // Do nothing + listener.noQRCodeFound(); + } finally { + // Must be called else new images won't be received or camera may stall (depending on back pressure setting) + image.close(); + } + } + + private void checkQrCode(Result qrCode) { + if (qrCode != null) { + listener.onQRCodeFound(qrCode.getText()); + } else { + listener.noQRCodeFound(); + } + } + + public interface Listener { + void onQRCodeFound(String qrCodeData); + + void noQRCodeFound(); + + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/QrCodeScannerFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/QrCodeScannerFragment.kt new file mode 100644 index 000000000..63c55060f --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/QrCodeScannerFragment.kt @@ -0,0 +1,268 @@ +package ch.admin.bag.dp3t.checkin.checkinflow + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.appcompat.app.AlertDialog +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.BuildConfig +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.models.CheckInState +import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState +import ch.admin.bag.dp3t.checkin.utils.ErrorDialog +import ch.admin.bag.dp3t.checkin.utils.ErrorHelper +import ch.admin.bag.dp3t.databinding.FragmentQrCodeScannerBinding +import ch.admin.bag.dp3t.extensions.isPackageInstalled +import ch.admin.bag.dp3t.extensions.showFragment +import org.crowdnotifier.android.sdk.CrowdNotifier +import org.crowdnotifier.android.sdk.utils.QrUtils.* +import java.util.concurrent.ExecutionException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private const val PERMISSION_REQUEST_CAMERA = 13 +private const val MIN_ERROR_VISIBILITY = 1000L +private const val COVID_CERT_PACKAGE_NAME = "ch.admin.bag.covidcertificate.wallet" +private const val COVID_CERT_PLAYSTORE_DEEP_LINK = "market://details?id=$COVID_CERT_PACKAGE_NAME" +private const val COVID_CERT_DEEPLINK_PREFIX = "covidcert://" +private const val COVID_CERT_QRCODE_PREFIX = "HC1:" + +class QrCodeScannerFragment : Fragment(), QrCodeAnalyzer.Listener { + + + companion object { + val TAG = QrCodeScannerFragment::class.java.canonicalName + + @JvmStatic + fun newInstance(): QrCodeScannerFragment = QrCodeScannerFragment() + } + + + private val viewModel: CrowdNotifierViewModel by activityViewModels() + + private var cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + private var isQRScanningEnabled = true + private var lastUIErrorUpdate = 0L + + private lateinit var binding: FragmentQrCodeScannerBinding + + override fun onResume() { + isQRScanningEnabled = true + super.onResume() + } + + override fun onStart() { + super.onStart() + if (ActivityCompat.checkSelfPermission(requireActivity(), Manifest.permission.CAMERA) != + PackageManager.PERMISSION_GRANTED + ) { + val dialog = CameraPermissionExplanationDialog(requireContext()) + dialog.setOnCancelListener { refreshView(false) } + dialog.setGrantCameraAccessClickListener { + requestPermissions(arrayOf(Manifest.permission.CAMERA), PERMISSION_REQUEST_CAMERA) + } + dialog.show() + } else { + startCameraAndQrAnalyzer() + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentQrCodeScannerBinding.inflate(inflater) + return binding.apply { + fragmentQrScannerToolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } + }.root + } + + private fun setupFlashButton(camera: Camera) { + binding.apply { + flashButton.isVisible = camera.cameraInfo.hasFlashUnit() + + camera.cameraInfo.torchState.observe(viewLifecycleOwner, { v: Int -> + if (v == TorchState.ON) { + flashButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_light_on)) + } else { + flashButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_light_off)) + } + }) + flashButton.setOnClickListener { camera.cameraControl.enableTorch(camera.cameraInfo.torchState.value == TorchState.OFF) } + } + } + + private fun startCameraAndQrAnalyzer() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) + cameraProviderFuture.addListener({ + try { + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build() + preview.setSurfaceProvider(binding.cameraPreview.surfaceProvider) + val imageAnalyzer = ImageAnalysis.Builder().build() + imageAnalyzer.setAnalyzer(cameraExecutor, QrCodeAnalyzer(this)) + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + cameraProvider.unbindAll() + val camera = cameraProvider.bindToLifecycle(viewLifecycleOwner, cameraSelector, preview, imageAnalyzer) + setupFlashButton(camera) + } catch (e: ExecutionException) { + Log.d(TAG, "Error starting camera " + e.message) + throw RuntimeException(e) + } catch (e: InterruptedException) { + Log.w("QR Analysis Interrupted", e.message!!) + Thread.currentThread().interrupt() + } + }, ContextCompat.getMainExecutor(requireContext())) + } + + override fun onDestroy() { + super.onDestroy() + cameraExecutor.shutdown() + } + + override fun noQRCodeFound() { + activity?.runOnUiThread { indicateInvalidQrCode(QRScannerState.NO_CODE_FOUND) } + } + + @Synchronized + override fun onQRCodeFound(qrCodeData: String) { + if (!isQRScanningEnabled) return + try { + val venueInfo = CrowdNotifier.getVenueInfo(qrCodeData, BuildConfig.ENTRY_QR_CODE_HOST) + isQRScanningEnabled = false + activity?.runOnUiThread { + viewModel.checkInState = CheckInState(false, venueInfo, System.currentTimeMillis(), System.currentTimeMillis(), 0) + } + showFragment(CheckInFragment.newInstance(isSelfCheckin = false)) + activity?.runOnUiThread { indicateInvalidQrCode(QRScannerState.VALID) } + } catch (e: QRException) { + handleInvalidQRCodeExceptions(e, qrCodeData) + } + } + + private fun handleInvalidQRCodeExceptions(e: QRException, qrCodeData: String) { + when (e) { + is InvalidQRCodeVersionException -> { + activity?.runOnUiThread { + isQRScanningEnabled = false + val errorDialog = ErrorDialog(requireContext(), CrowdNotifierErrorState.UPDATE_REQUIRED) + errorDialog.setOnDismissListener { isQRScanningEnabled = true } + errorDialog.setOnCancelListener { isQRScanningEnabled = true } + errorDialog.show() + } + } + is NotYetValidException -> activity?.runOnUiThread { indicateInvalidQrCode(QRScannerState.NOT_YET_VALID) } + is NotValidAnymoreException -> activity?.runOnUiThread { indicateInvalidQrCode(QRScannerState.NOT_VALID_ANYMORE) } + else -> activity?.runOnUiThread { + if (qrCodeData.startsWith(COVID_CERT_QRCODE_PREFIX)) { + showCovidCertificateAlert(qrCodeData) + } else { + indicateInvalidQrCode(QRScannerState.INVALID_FORMAT) + } + } + } + } + + private fun showCovidCertificateAlert(qrCodeData: String) { + val context = context ?: return + isQRScanningEnabled = false + AlertDialog.Builder(context, R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.covid_certificate_alert_text) + .apply { + if (context.packageManager.isPackageInstalled(COVID_CERT_PACKAGE_NAME)) { + setPositiveButton(R.string.covid_certificate_open_app) { _, _ -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$COVID_CERT_DEEPLINK_PREFIX$qrCodeData")) + if (intent.resolveActivity(context.packageManager) != null) { + startActivity(intent) + } else { + startActivity(context.packageManager.getLaunchIntentForPackage(COVID_CERT_PACKAGE_NAME)) + } + } + } else { + setPositiveButton(R.string.covid_certificate_install_app) { _, _ -> + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(COVID_CERT_PLAYSTORE_DEEP_LINK))) + } + } + } + .setOnDismissListener { isQRScanningEnabled = true } + .setOnCancelListener { isQRScanningEnabled = true } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .show() + + } + + private fun indicateInvalidQrCode(qrScannerState: QRScannerState) { + binding.apply { + val currentTime = System.currentTimeMillis() + if (lastUIErrorUpdate > currentTime - MIN_ERROR_VISIBILITY && qrScannerState == QRScannerState.NO_CODE_FOUND) { + return + } + lastUIErrorUpdate = currentTime + var color = R.color.primary + if (qrScannerState == QRScannerState.VALID || qrScannerState == QRScannerState.NO_CODE_FOUND) { + qrCodeScannerInvalidCodeText.visibility = View.INVISIBLE + } else { + qrCodeScannerInvalidCodeText.visibility = View.VISIBLE + color = R.color.status_red + } + when (qrScannerState) { + QRScannerState.INVALID_FORMAT -> qrCodeScannerInvalidCodeText.setText(R.string.qrscanner_error) + QRScannerState.NOT_VALID_ANYMORE -> qrCodeScannerInvalidCodeText.setText(R.string.qr_scanner_error_code_not_valid_anymore) + QRScannerState.NOT_YET_VALID -> qrCodeScannerInvalidCodeText.setText(R.string.qr_scanner_error_code_not_yet_valid) + else -> Unit + } + setIndicatorColor(qrCodeScannerTopLeftIndicator, color) + setIndicatorColor(qrCodeScannerTopRightIndicator, color) + setIndicatorColor(qrCodeScannerBottomLeftIndicator, color) + setIndicatorColor(qrCodeScannerBottomRightIndicator, color) + } + } + + private fun setIndicatorColor(indicator: View, @ColorRes color: Int) { + val drawable = indicator.background as LayerDrawable + val stroke = drawable.findDrawableByLayerId(R.id.indicator) as GradientDrawable + if (context == null) return + stroke.setStroke( + resources.getDimensionPixelSize(R.dimen.qr_scanner_indicator_stroke_width), + resources.getColor(color, null) + ) + } + + private fun refreshView(cameraPermissionGranted: Boolean) { + binding.apply { + fragmentQrScannerMainView.isVisible = cameraPermissionGranted + fragmentQrScannerErrorView.isVisible = !cameraPermissionGranted + + if (cameraPermissionGranted) { + startCameraAndQrAnalyzer() + } else { + ErrorHelper.updateErrorView(fragmentQrScannerErrorView, CrowdNotifierErrorState.CAMERA_ACCESS_DENIED, null, context) + } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (requestCode == PERMISSION_REQUEST_CAMERA) { + refreshView(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) + } + } + + internal enum class QRScannerState { + NO_CODE_FOUND, VALID, INVALID_FORMAT, NOT_YET_VALID, NOT_VALID_ANYMORE + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/DiaryFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/DiaryFragment.kt new file mode 100644 index 000000000..1bb6fc321 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/DiaryFragment.kt @@ -0,0 +1,99 @@ +package ch.admin.bag.dp3t.checkin.diary + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.checkinflow.CheckOutFragment +import ch.admin.bag.dp3t.checkin.diary.items.ItemVenueVisit +import ch.admin.bag.dp3t.checkin.diary.items.ItemVenueVisitCurrent +import ch.admin.bag.dp3t.checkin.diary.items.ItemVenueVisitDayHeader +import ch.admin.bag.dp3t.checkin.diary.items.VenueVisitRecyclerItem +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.databinding.FragmentCheckinDiaryBinding +import ch.admin.bag.dp3t.extensions.showFragment +import ch.admin.bag.dp3t.reports.CheckinReportItem +import ch.admin.bag.dp3t.reports.ReportsFragment +import ch.admin.bag.dp3t.util.DateUtils +import org.crowdnotifier.android.sdk.model.ExposureEvent +import java.util.* + +class DiaryFragment : Fragment() { + + private val crowdNotifierViewModel: CrowdNotifierViewModel by activityViewModels() + + companion object { + fun newInstance() = DiaryFragment() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentCheckinDiaryBinding.inflate(inflater, container, false).apply { + checkinDiaryToolbar.setNavigationOnClickListener { + parentFragmentManager.popBackStack() + } + + val recyclerAdapter = DiaryRecyclerViewAdapter() + checkinDiaryRecyclerView.adapter = recyclerAdapter + + crowdNotifierViewModel.isCheckedIn.observe(viewLifecycleOwner) { isCheckedIn -> + if (isCheckedIn) { + recyclerAdapter.setCurrentCheckinData( + ItemVenueVisitDayHeader(getString(R.string.diary_current_title)), + ItemVenueVisitCurrent(crowdNotifierViewModel.checkInState) + ) { + showFragment(CheckOutFragment.newInstance(), modalAnimation = true) + } + crowdNotifierViewModel.startCheckInTimer() + } else { + recyclerAdapter.setCurrentCheckinDataNone() + } + } + + crowdNotifierViewModel.timeSinceCheckIn.observe(viewLifecycleOwner) { time -> + if (time > 0) { + recyclerAdapter.updateCurrentCheckinData() + } + } + + crowdNotifierViewModel.exposures.observe(viewLifecycleOwner) { exposures -> + val items = ArrayList() + val diaryEntries = DiaryStorage.getInstance(context).entries.sortedByDescending { + it.checkInTime + } + val isEmpty = diaryEntries.isEmpty() + checkinDiaryEmptyView.isVisible = isEmpty + var daysAgoString = "" + for (diaryEntry in diaryEntries) { + val newDaysAgoString: String = DateUtils.getFormattedWeekdayWithDate(diaryEntry.checkInTime, requireContext()) + if (newDaysAgoString != daysAgoString) { + daysAgoString = newDaysAgoString + items.add(ItemVenueVisitDayHeader(daysAgoString)) + } + items.add(ItemVenueVisit(getExposureWithId(exposures, diaryEntry.id), diaryEntry) { + onDiaryEntryClicked(diaryEntry, getExposureWithId(exposures, diaryEntry.id)) + }) + } + recyclerAdapter.setDiaryData(items) + } + }.root + } + + private fun getExposureWithId(exposures: List, id: Long): ExposureEvent? { + return exposures.firstOrNull { it.id == id } + } + + private fun onDiaryEntryClicked(diaryEntry: DiaryEntry, exposureEvent: ExposureEvent?) { + if (exposureEvent != null) { + showFragment(ReportsFragment.newInstance(reportItem = CheckinReportItem(exposureEvent, diaryEntry))) + } else { + showFragment(EditDiaryEntryFragment.newInstance(diaryEntry.id), modalAnimation = true) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/DiaryRecyclerViewAdapter.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/DiaryRecyclerViewAdapter.java new file mode 100644 index 000000000..811bdd86c --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/DiaryRecyclerViewAdapter.java @@ -0,0 +1,184 @@ +package ch.admin.bag.dp3t.checkin.diary; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; + +import org.crowdnotifier.android.sdk.model.VenueInfo; + +import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.diary.items.ItemVenueVisit; +import ch.admin.bag.dp3t.checkin.diary.items.ItemVenueVisitCurrent; +import ch.admin.bag.dp3t.checkin.diary.items.ItemVenueVisitDayHeader; +import ch.admin.bag.dp3t.checkin.diary.items.VenueVisitRecyclerItem; +import ch.admin.bag.dp3t.checkin.models.CheckInState; +import ch.admin.bag.dp3t.extensions.CommonVenueInfoExtensionsKt; +import ch.admin.bag.dp3t.util.StringUtil; + +public class DiaryRecyclerViewAdapter extends RecyclerView.Adapter { + + private final List currentCheckinItems = new ArrayList<>(); + private final List diaryItems = new ArrayList<>(); + private Runnable onCheckoutListener; + + private VenueVisitRecyclerItem getItem(int position) { + if (position < currentCheckinItems.size()) { + return currentCheckinItems.get(position); + } else { + return diaryItems.get(position - currentCheckinItems.size()); + } + } + + @Override + public int getItemViewType(int position) { return getItem(position).getViewType().ordinal(); } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + VenueVisitRecyclerItem.ViewType type = VenueVisitRecyclerItem.ViewType.values()[viewType]; + switch (type) { + case DAY_HEADER: + return new DayHeaderViewHolder( + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_checkin_venue_visits_day_header, parent, false)); + case VENUE: + return new VenueVisitViewHolder( + LayoutInflater.from(parent.getContext()).inflate(R.layout.item_checkin_venue_visit, parent, false)); + case CURRENT: + return new VenueVisitCurrentViewHolder( + LayoutInflater.from(parent.getContext()).inflate(R.layout.item_checkin_venue_visit_current, parent, false)); + default: + throw new IllegalArgumentException(); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + VenueVisitRecyclerItem item = getItem(position); + switch (item.getViewType()) { + case DAY_HEADER: + ((DayHeaderViewHolder) holder).bind((ItemVenueVisitDayHeader) item); + break; + case VENUE: + ((VenueVisitViewHolder) holder).bind((ItemVenueVisit) item); + break; + case CURRENT: + ((VenueVisitCurrentViewHolder) holder).bind((ItemVenueVisitCurrent) item); + break; + } + } + + @Override + public int getItemCount() { + return currentCheckinItems.size() + diaryItems.size(); + } + + public void setCurrentCheckinData(ItemVenueVisitDayHeader headerItem, ItemVenueVisitCurrent checkinItem, Runnable onCheckoutListener) { + this.onCheckoutListener = onCheckoutListener; + + boolean hadCheckin = !currentCheckinItems.isEmpty(); + if (hadCheckin) { + notifyItemChanged(1, checkinItem); + } else { + currentCheckinItems.add(headerItem); + currentCheckinItems.add(checkinItem); + notifyItemRangeInserted(0, currentCheckinItems.size()); + } + } + + public void updateCurrentCheckinData() { + if (!currentCheckinItems.isEmpty()) { + notifyItemChanged(1, currentCheckinItems.get(1)); + } + } + + public void setCurrentCheckinDataNone() { + int itemsToRemove = currentCheckinItems.size(); + if (itemsToRemove > 0) { + currentCheckinItems.clear(); + notifyItemRangeRemoved(0, itemsToRemove); + } + } + + public void setDiaryData(List items) { + diaryItems.clear(); + diaryItems.addAll(items); + notifyDataSetChanged(); + } + + + public static class DayHeaderViewHolder extends RecyclerView.ViewHolder { + + private final TextView dayLabel; + + public DayHeaderViewHolder(View itemView) { + super(itemView); + dayLabel = itemView.findViewById(R.id.item_diary_day_header_text_view); + } + + public void bind(ItemVenueVisitDayHeader item) { + dayLabel.setText(item.getDayLabel()); + } + + } + + + public static class VenueVisitViewHolder extends RecyclerView.ViewHolder { + + private final TextView timeTextView; + private final TextView nameTextView; + private final ImageView statusIcon; + + public VenueVisitViewHolder(View itemView) { + super(itemView); + this.timeTextView = itemView.findViewById(R.id.item_diary_entry_time); + this.nameTextView = itemView.findViewById(R.id.item_diary_entry_name); + this.statusIcon = itemView.findViewById(R.id.item_diary_entry_status_icon); + } + + public void bind(ItemVenueVisit item) { + VenueInfo venueInfo = item.getDiaryEntry().getVenueInfo(); + nameTextView.setText(venueInfo.getTitle()); + String start = StringUtil.getHourMinuteTimeString(item.getDiaryEntry().getCheckInTime(), ":"); + String end = StringUtil.getHourMinuteTimeString(item.getDiaryEntry().getCheckOutTime(), ":"); + timeTextView.setText(start + " – " + end); + if (item.getExposure() == null) { + statusIcon.setVisibility(View.GONE); + } else { + statusIcon.setVisibility(View.VISIBLE); + } + itemView.setOnClickListener(item.getOnClickListener()); + } + + } + + + public class VenueVisitCurrentViewHolder extends RecyclerView.ViewHolder { + + private final TextView nameTextView; + private final TextView timeTextView; + + public VenueVisitCurrentViewHolder(View itemView) { + super(itemView); + nameTextView = itemView.findViewById(R.id.item_diary_current_name); + timeTextView = itemView.findViewById(R.id.item_diary_current_time); + itemView.findViewById(R.id.item_diary_checkout_button).setOnClickListener(v -> onCheckoutListener.run()); + } + + public void bind(ItemVenueVisitCurrent item) { + CheckInState checkInState = item.getCheckInState(); + nameTextView.setText(checkInState.getVenueInfo().getTitle()); + long duration = System.currentTimeMillis() - checkInState.getCheckInTime(); + timeTextView.setText(StringUtil.getShortDurationString(duration)); + } + + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/EditDiaryEntryFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/EditDiaryEntryFragment.kt new file mode 100644 index 000000000..6fe7e513d --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/EditDiaryEntryFragment.kt @@ -0,0 +1,62 @@ +package ch.admin.bag.dp3t.checkin.diary + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.EditCheckinBaseFragment +import ch.admin.bag.dp3t.checkin.models.CheckinInfo +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.databinding.FragmentCheckOutAndEditBinding +import org.crowdnotifier.android.sdk.CrowdNotifier + +class EditDiaryEntryFragment : EditCheckinBaseFragment() { + + companion object { + private const val ARG_DIARY_ENTRY_ID = "ARG_DIARY_ENTRY_ID" + + @JvmStatic + fun newInstance(diaryEntryId: Long) = EditDiaryEntryFragment().apply { + arguments = bundleOf(ARG_DIARY_ENTRY_ID to diaryEntryId) + } + } + + private lateinit var diaryStorage: DiaryStorage + private lateinit var diaryEntry: DiaryEntry + + override val checkinInfo: CheckinInfo + get() = diaryEntry + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + diaryStorage = DiaryStorage.getInstance(requireContext()) + val diaryEntryId = requireArguments().getLong(ARG_DIARY_ENTRY_ID) + diaryEntry = diaryStorage.getDiaryEntryWithId(diaryEntryId) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + FragmentCheckOutAndEditBinding.bind(view).apply { + toolbarDoneButton.setOnClickListener { performSave() } + + checkoutPrimaryButton.setText(R.string.remove_from_diary_button) + checkoutPrimaryButton.setOnClickListener { hideInDiary() } + } + } + + override fun saveEntry() { + diaryEntry.run { + CrowdNotifier.updateCheckIn(id, checkInTime, checkOutTime, venueInfo, context) + } + diaryStorage.updateEntry(diaryEntry) + + requireActivity().supportFragmentManager.popBackStack() + } + + private fun hideInDiary() { + requireActivity().supportFragmentManager.beginTransaction() + .add(HideInDiaryDialogFragment.newInstance(diaryEntry.id), HideInDiaryDialogFragment.TAG) + .commit() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/HideInDiaryDialogFragment.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/HideInDiaryDialogFragment.java new file mode 100644 index 000000000..0c2b2f15c --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/HideInDiaryDialogFragment.java @@ -0,0 +1,81 @@ +package ch.admin.bag.dp3t.checkin.diary; + +import android.graphics.Paint; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import org.crowdnotifier.android.sdk.CrowdNotifier; + +import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.models.DiaryEntry; +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage; + +public class HideInDiaryDialogFragment extends DialogFragment { + + public static final String TAG = HideInDiaryDialogFragment.class.getCanonicalName(); + private static final String ARG_DIARY_ENTRY_ID = "ARG_DIARY_ENTRY_ID"; + + private DiaryStorage diaryStorage; + private DiaryEntry diaryEntry; + + public static HideInDiaryDialogFragment newInstance(long entryId) { + HideInDiaryDialogFragment fragment = new HideInDiaryDialogFragment(); + Bundle args = new Bundle(); + args.putLong(ARG_DIARY_ENTRY_ID, entryId); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + diaryStorage = DiaryStorage.getInstance(getContext()); + diaryEntry = diaryStorage.getDiaryEntryWithId(getArguments().getLong(ARG_DIARY_ENTRY_ID)); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.dialog_fragment_hide_in_diary, container); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + View closeButton = view.findViewById(R.id.checkin_remove_close_button); + View hideButton = view.findViewById(R.id.checkin_remove_hide_button); + TextView nukeButton = view.findViewById(R.id.checkin_remove_nuke_button); + + nukeButton.setPaintFlags(nukeButton.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + + closeButton.setOnClickListener(v -> dismiss()); + hideButton.setOnClickListener(v -> hideNow()); + nukeButton.setOnClickListener(v -> nukeNow()); + } + + @Override + public void onResume() { + getDialog().getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + super.onResume(); + } + + private void hideNow() { + diaryStorage.removeEntry(diaryEntry.getId()); + dismiss(); + requireActivity().getSupportFragmentManager().popBackStack(); + } + + private void nukeNow() { + diaryStorage.removeEntry(diaryEntry.getId()); + CrowdNotifier.deleteCheckIn(diaryEntry.getId(), requireContext()); + dismiss(); + requireActivity().getSupportFragmentManager().popBackStack(); + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisit.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisit.java new file mode 100644 index 000000000..d5cf871ac --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisit.java @@ -0,0 +1,38 @@ +package ch.admin.bag.dp3t.checkin.diary.items; + +import android.view.View; + +import org.crowdnotifier.android.sdk.model.ExposureEvent; + +import ch.admin.bag.dp3t.checkin.models.DiaryEntry; + +public class ItemVenueVisit extends VenueVisitRecyclerItem { + + private final ExposureEvent exposure; + private final DiaryEntry diaryEntry; + private final View.OnClickListener onClickListener; + + public ItemVenueVisit(ExposureEvent exposure, DiaryEntry diaryEntry, View.OnClickListener onClickListener) { + this.exposure = exposure; + this.diaryEntry = diaryEntry; + this.onClickListener = onClickListener; + } + + public ExposureEvent getExposure() { + return exposure; + } + + public DiaryEntry getDiaryEntry() { + return diaryEntry; + } + + public View.OnClickListener getOnClickListener() { + return onClickListener; + } + + @Override + public ViewType getViewType() { + return ViewType.VENUE; + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisitCurrent.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisitCurrent.java new file mode 100644 index 000000000..d0d1509fb --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisitCurrent.java @@ -0,0 +1,22 @@ +package ch.admin.bag.dp3t.checkin.diary.items; + +import ch.admin.bag.dp3t.checkin.models.CheckInState; + +public class ItemVenueVisitCurrent extends VenueVisitRecyclerItem { + + private final CheckInState checkInState; + + public ItemVenueVisitCurrent(CheckInState checkInState) { + this.checkInState = checkInState; + } + + public CheckInState getCheckInState() { + return checkInState; + } + + @Override + public ViewType getViewType() { + return ViewType.CURRENT; + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisitDayHeader.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisitDayHeader.java new file mode 100644 index 000000000..2c91b13a0 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/ItemVenueVisitDayHeader.java @@ -0,0 +1,20 @@ +package ch.admin.bag.dp3t.checkin.diary.items; + +public class ItemVenueVisitDayHeader extends VenueVisitRecyclerItem { + + private final String dayLabel; + + public ItemVenueVisitDayHeader(String dayLabel) { + this.dayLabel = dayLabel; + } + + public String getDayLabel() { + return dayLabel; + } + + @Override + public ViewType getViewType() { + return ViewType.DAY_HEADER; + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/VenueVisitRecyclerItem.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/VenueVisitRecyclerItem.java new file mode 100644 index 000000000..e1457c220 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/diary/items/VenueVisitRecyclerItem.java @@ -0,0 +1,11 @@ +package ch.admin.bag.dp3t.checkin.diary.items; + +public abstract class VenueVisitRecyclerItem { + + public enum ViewType { + VENUE, DAY_HEADER, CURRENT + } + + public abstract ViewType getViewType(); + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/EventsOverviewFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/EventsOverviewFragment.kt new file mode 100644 index 000000000..33052570d --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/EventsOverviewFragment.kt @@ -0,0 +1,60 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentEventsOverviewBinding +import ch.admin.bag.dp3t.extensions.showFragment +import org.crowdnotifier.android.sdk.model.VenueInfo +import ch.admin.bag.dp3t.util.UrlUtil + + +class EventsOverviewFragment : Fragment() { + + companion object { + fun newInstance() = EventsOverviewFragment() + } + + private val qrCodeViewModel: QRCodeViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentEventsOverviewBinding.inflate(layoutInflater).apply { + eventsToolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } + + val adapter = QrCodeAdapter(object : OnClickListener { + override fun generateQrCode() { + showFragment(GenerateQrCodeFragment.newInstance(), modalAnimation = true) + } + + override fun onQrCodeClicked(qrCodeItem: VenueInfo) { + showFragment(QrCodeFragment.newInstance(qrCodeItem), modalAnimation = true) + } + + override fun onFaqClicked() { + UrlUtil.openUrl(context, getString(R.string.faq_button_url)) + } + }) + + qrList.adapter = adapter + qrCodeViewModel.generatedQrCodesLiveData.observe(viewLifecycleOwner) { events -> + if (events.isEmpty()) { + adapter.setItems(listOf(ExplanationItem(showOnlyInfobox = false), FooterItem(), FaqButtonItem())) + eventsToolbar.title = getString(R.string.checkins_create_qr_code) + } else { + adapter.setItems(events.map { EventItem(it) }.toMutableList().apply { + add(0, GenerateQrCodeButtonItem()) + add(ExplanationItem(showOnlyInfobox = true)) + add(FooterItem()) + add(FaqButtonItem()) + }) + eventsToolbar.title = getString(R.string.events_card_title_events_not_empty) + } + } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/GenerateQrCodeFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/GenerateQrCodeFragment.kt new file mode 100644 index 000000000..2a9bd9749 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/GenerateQrCodeFragment.kt @@ -0,0 +1,55 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.doOnLayout +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.databinding.FragmentGenerateQrCodeBinding +import ch.admin.bag.dp3t.extensions.showFragment + +class GenerateQrCodeFragment : Fragment() { + + companion object { + fun newInstance() = GenerateQrCodeFragment() + } + + private lateinit var binding: FragmentGenerateQrCodeBinding + private val qrCodeViewModel: QRCodeViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentGenerateQrCodeBinding.inflate(layoutInflater).apply { + generateQrCodeCancel.setOnClickListener { popFragmentAndHideKeyboard() } + + qrCodeGenerate.setOnClickListener { generateQrCode(titleEditText.text.toString()) } + qrCodeGenerate.isEnabled = !titleEditText.text.isNullOrBlank() + titleEditText.doOnTextChanged { text, _, _, _ -> qrCodeGenerate.isEnabled = !text.isNullOrBlank() } + + titleEditText.doOnLayout { + it.requestFocus() + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) + } + } + return binding.root + } + + private fun generateQrCode(title: String) { + qrCodeViewModel.generateAndSaveQrCode(title).observe(viewLifecycleOwner) { + requireActivity().supportFragmentManager.popBackStack() + showFragment(QrCodeFragment.newInstance(it)) + } + } + + private fun popFragmentAndHideKeyboard() { + requireActivity().supportFragmentManager.popBackStack() + val inputMethodManager = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(binding.titleEditText.windowToken, 0) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/GeneratedQrCodesSerializer.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/GeneratedQrCodesSerializer.kt new file mode 100644 index 000000000..d9934a38a --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/GeneratedQrCodesSerializer.kt @@ -0,0 +1,22 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import ch.admin.bag.dp3t.checkin.models.GeneratedQrCodesWrapper +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +object GeneratedQrCodesSerializer : Serializer { + override val defaultValue: GeneratedQrCodesWrapper = GeneratedQrCodesWrapper.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): GeneratedQrCodesWrapper { + try { + return GeneratedQrCodesWrapper.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: GeneratedQrCodesWrapper, output: OutputStream) = t.writeTo(output) +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/PdfCreator.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/PdfCreator.kt new file mode 100644 index 000000000..0cd9532f2 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/PdfCreator.kt @@ -0,0 +1,98 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.content.Context +import android.graphics.* +import android.graphics.pdf.PdfDocument +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.ColorInt +import ch.admin.bag.dp3t.BuildConfig +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.PdfQrCodeBinding +import org.crowdnotifier.android.sdk.model.VenueInfo + + +private const val PDF_WIDTH = 595 +private const val PDF_HEIGHT = 842 +private const val PDF_QR_CODE_MAX_PIXEL_SIZE = 310 + +fun createEntryPdf(venueInfo: VenueInfo, context: Context): PdfDocument { + + val bitmap = QrCode.create(venueInfo.toQrCodeString("https://" + BuildConfig.ENTRY_QR_CODE_HOST)) + .renderToMaxSizeBitmap(PDF_QR_CODE_MAX_PIXEL_SIZE) + + val document = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(PDF_WIDTH, PDF_HEIGHT, 1).create() // A4 size + val page = document.startPage(pageInfo) + page.canvas.apply { + val indicatorOffset = 15f + val qrCodeY = 150f + drawLinesAroundQrCode( + start = (PDF_WIDTH - bitmap.width) / 2f - indicatorOffset, + top = qrCodeY - indicatorOffset, + end = PDF_WIDTH - (PDF_WIDTH - bitmap.width) / 2f + indicatorOffset, + bottom = qrCodeY + bitmap.height + indicatorOffset, + this, + swissCovidBlue + ) + drawBitmap(bitmap, (PDF_WIDTH - bitmap.width) / 2f, qrCodeY, Paint()) + + drawText( + context.getString(R.string.check_in_now_button_title), + PDF_WIDTH / 2f, + qrCodeY + bitmap.height + 2 * indicatorOffset, + blueCenteredBoldPaint + ) + + val pdfView = PdfQrCodeBinding.inflate(LayoutInflater.from(context)).apply { + title.text = venueInfo.title + }.root + + pdfView.measure( + View.MeasureSpec.makeMeasureSpec(PDF_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(PDF_HEIGHT, View.MeasureSpec.EXACTLY) + ) + pdfView.layout(0, 0, PDF_WIDTH, PDF_HEIGHT) + pdfView.draw(this) + } + + document.finishPage(page) + return document + +} + +private val swissCovidBlue = Color.parseColor("#5094bf") + +private val blueCenteredBoldPaint = Paint().apply { + color = swissCovidBlue + textAlign = Paint.Align.CENTER + textSize = 13f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) +} + +private fun drawLinesAroundQrCode(start: Float, top: Float, end: Float, bottom: Float, canvas: Canvas, @ColorInt strokeColor: Int) { + val indicatorWidth = (end - start) / 5 + + val linePaint = Paint().apply { + color = strokeColor + strokeWidth = 6f + style = Paint.Style.STROKE + } + canvas.apply { + drawPath(Path().apply { + moveTo(start, top + indicatorWidth) + lineTo(start, top) + lineTo(start + indicatorWidth, top) + moveTo(end - indicatorWidth, top) + lineTo(end, top) + lineTo(end, top + indicatorWidth) + moveTo(end, bottom - indicatorWidth) + lineTo(end, bottom) + lineTo(end - indicatorWidth, bottom) + moveTo(start + indicatorWidth, bottom) + lineTo(start, bottom) + lineTo(start, bottom - indicatorWidth) + + }, linePaint) + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/PdfPrintDocumentAdapter.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/PdfPrintDocumentAdapter.kt new file mode 100644 index 000000000..ddcdd8268 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/PdfPrintDocumentAdapter.kt @@ -0,0 +1,63 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.os.Bundle +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.print.PageRange +import android.print.PrintAttributes +import android.print.PrintDocumentAdapter +import android.print.PrintDocumentInfo +import android.util.Log +import java.io.* + +class PdfPrintDocumentAdapter(private val file: File) : PrintDocumentAdapter() { + + override fun onLayout( + oldAttributes: PrintAttributes?, + newAttributes: PrintAttributes?, + cancellationSignal: CancellationSignal, + callback: LayoutResultCallback, + extras: Bundle? + ) { + if (cancellationSignal.isCanceled) { + callback.onLayoutCancelled() + return + } + + val info = PrintDocumentInfo.Builder("swisscovid-qr-code.pdf") + .setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .setPageCount(PrintDocumentInfo.PAGE_COUNT_UNKNOWN) + .build() + + callback.onLayoutFinished(info, oldAttributes != newAttributes) + } + + override fun onWrite( + pages: Array, + destination: ParcelFileDescriptor, + cancellationSignal: CancellationSignal, + callback: WriteResultCallback + ) { + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + + try { + inputStream = FileInputStream(file) + outputStream = FileOutputStream(destination.fileDescriptor) + + inputStream.copyTo(outputStream) + + if (cancellationSignal.isCanceled) { + callback.onWriteCancelled() + } else { + callback.onWriteFinished(arrayOf(PageRange.ALL_PAGES)) + } + } catch (ex: Exception) { + callback.onWriteFailed(ex.message) + Log.e("PDFDocumentAdapter", "Could not write: ${ex.localizedMessage}") + } finally { + inputStream?.close() + outputStream?.close() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QRCodeViewModel.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QRCodeViewModel.kt new file mode 100644 index 000000000..76039d075 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QRCodeViewModel.kt @@ -0,0 +1,131 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.app.Application +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import androidx.lifecycle.* +import ch.admin.bag.dp3t.BuildConfig +import ch.admin.bag.dp3t.checkin.models.GeneratedQrCodesWrapper +import ch.admin.bag.dp3t.checkin.models.SwissCovidLocationData +import ch.admin.bag.dp3t.checkin.models.VenueType +import ch.admin.bag.dp3t.checkin.utils.SingleLiveEvent +import ch.admin.bag.dp3t.extensions.toQrCodePayload +import ch.admin.bag.dp3t.extensions.toVenueInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.crowdnotifier.android.sdk.CrowdNotifier +import org.crowdnotifier.android.sdk.model.VenueInfo +import org.crowdnotifier.android.sdk.utils.Base64Util +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import kotlin.math.max + + +private const val ONE_MINUTE_IN_MILLIS = 60 * 1000L +private const val ONE_HOUR_IN_MILLIS = 60 * ONE_MINUTE_IN_MILLIS +private const val AUTOMATIC_CHECKOUT_DELAY_MS = 12 * ONE_HOUR_IN_MILLIS +private const val CHECKOUT_WARNING_DELAY_MS = 8 * ONE_HOUR_IN_MILLIS +private const val SWISSCOVID_LOCATION_DATA_VERSION = 4 +private const val QR_CODE_VALIDITY_DURATION_MS = 100000 * 24 * ONE_HOUR_IN_MILLIS // 100'000 days +private val REMINDER_DELAY_OPTIONS_MS = + listOf(if (BuildConfig.IS_FLAVOR_DEV) 1 else 30, 60, 120).map { it * ONE_MINUTE_IN_MILLIS } // 30, 60, and 120 minutes +private const val MAX_QR_CODE_PIXEL_SIZE = 1000 +const val QR_CODE_PDF_FILE_NAME = "swisscovid-qr-code.pdf" + +private val Context.generatedQrCodesDataStore: DataStore by dataStore( + fileName = "generatedQrCodes.pb", + serializer = GeneratedQrCodesSerializer +) + +class QRCodeViewModel(application: Application) : AndroidViewModel(application) { + + private val generatedQrCodesFlow = application.generatedQrCodesDataStore.data.buffer(1).catch { exception -> + if (exception is IOException) { + Log.e("QRCodeViewModel", "Error reading generated QR Codes.", exception) + } else { + throw exception + } + } + + private val pdfDirectory = File(application.externalCacheDir, "pdfs").apply { if (!exists()) mkdirs() } + + val generatedQrCodesLiveData = generatedQrCodesFlow.map { wrapper -> + wrapper.generatedQrCodesList.map { it.toVenueInfo() } + }.asLiveData() + + val selectedQrCodeBitmap = SingleLiveEvent() + val selectedQrCodePdf = SingleLiveEvent() + + fun deleteQrCode(venueInfo: VenueInfo) = viewModelScope.launch { + val builder = GeneratedQrCodesWrapper.newBuilder() + generatedQrCodesFlow.first().generatedQrCodesList.forEach { + if (venueInfo.toQrCodePayload() != it) { + builder.addGeneratedQrCodes(it) + } + } + saveGeneratedQrCode(builder.build()) + } + + fun generateAndSaveQrCode(description: String) = liveData(Dispatchers.IO) { + + val swissCovidLocationData = SwissCovidLocationData.newBuilder() + .setVersion(SWISSCOVID_LOCATION_DATA_VERSION) + .setAutomaticCheckoutDelaylMs(AUTOMATIC_CHECKOUT_DELAY_MS) + .setCheckoutWarningDelayMs(CHECKOUT_WARNING_DELAY_MS) + .addAllReminderDelayOptionsMs(REMINDER_DELAY_OPTIONS_MS) + .setType(VenueType.USER_QR_CODE) + .build() + + val generatedVenueInfo = CrowdNotifier.generateVenueInfo( + description, + "", + swissCovidLocationData.toByteArray(), + System.currentTimeMillis() / 1000, + (System.currentTimeMillis() + QR_CODE_VALIDITY_DURATION_MS) / 1000, + Base64Util.fromBase64(BuildConfig.QR_MASTER_PUBLIC_KEY_BASE_64) + ) + + + val newWrapper = GeneratedQrCodesWrapper.newBuilder() + .addAllGeneratedQrCodes(generatedQrCodesFlow.first().generatedQrCodesList) + .addGeneratedQrCodes(generatedVenueInfo.toQrCodePayload()) + .build() + saveGeneratedQrCode(newWrapper) + emit(generatedVenueInfo) + } + + fun generateQrCodeBitmapAndPdf(venueInfo: VenueInfo, qrCodeSize: Int) = viewModelScope.launch(Dispatchers.IO) { + launch(Dispatchers.IO) { + selectedQrCodeBitmap.postValue( + QrCode.create(venueInfo.toQrCodeString("https://" + BuildConfig.ENTRY_QR_CODE_HOST)) + .renderToBitmap(max(qrCodeSize, MAX_QR_CODE_PIXEL_SIZE)) + ) + } + launch(Dispatchers.IO) { + val document = createEntryPdf(venueInfo, getApplication()) + + val file = File(pdfDirectory, QR_CODE_PDF_FILE_NAME) + + FileOutputStream(file).use { + document.writeTo(it) + document.close() + } + + selectedQrCodePdf.postValue(file) + } + } + + private suspend fun saveGeneratedQrCode(generatedQrCodesWrapper: GeneratedQrCodesWrapper) { + try { + getApplication().generatedQrCodesDataStore.updateData { generatedQrCodesWrapper } + } catch (e: Exception) { + throw RuntimeException(e) + } + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCode.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCode.kt new file mode 100644 index 000000000..f280092bb --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCode.kt @@ -0,0 +1,54 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.graphics.Bitmap +import android.graphics.Color +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.MultiFormatWriter +import kotlin.math.max + +class QrCode private constructor(val value: String, val size: Int) { + + companion object { + @JvmStatic + fun create(value: String): QrCode { + return QrCode(value, encode(value, 0).width) + } + + @JvmStatic + private fun encode(value: String, size: Int) = MultiFormatWriter().encode(value, BarcodeFormat.QR_CODE, size, size, mapOf(EncodeHintType.MARGIN to 0)) + } + + /** + * Renders a bitmap to the exact provided outsize. A margin might be added if the QR Code does not fit exactly into the provided + * outSize. + */ + fun renderToBitmap(outSize: Int = 0): Bitmap { + val bitMatrix = encode(value, outSize) + val bitmap = Bitmap.createBitmap(bitMatrix.width, bitMatrix.height, Bitmap.Config.RGB_565) + for (x in 0 until bitMatrix.width) { + for (y in 0 until bitMatrix.height) { + bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE) + } + } + return bitmap + } + + + /** + * Renders a bitmap to the closest possible size of maxOutSize but no larger than maxOutSize. No padding is added. + */ + fun renderToMaxSizeBitmap(maxOutSize: Int): Bitmap { + val bitMatrix = encode(value, 0) + val factor: Int = max(maxOutSize / bitMatrix.width, 1) + val bitmap = Bitmap.createBitmap(bitMatrix.width * factor, bitMatrix.height * factor, Bitmap.Config.RGB_565) + for (x in 0 until bitMatrix.width) { + for (y in 0 until bitMatrix.height) { + val intArray = IntArray(factor * factor) { if (bitMatrix[x, y]) Color.BLACK else Color.WHITE } + bitmap.setPixels(intArray, 0, factor, x * factor, y * factor, factor, factor) + } + } + return bitmap + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeAdapter.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeAdapter.kt new file mode 100644 index 000000000..a0ec3dea5 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeAdapter.kt @@ -0,0 +1,102 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import ch.admin.bag.dp3t.checkin.generateqrcode.EventOverviewItem.Companion.TYPE_EVENT +import ch.admin.bag.dp3t.checkin.generateqrcode.EventOverviewItem.Companion.TYPE_EXPLANATION +import ch.admin.bag.dp3t.checkin.generateqrcode.EventOverviewItem.Companion.TYPE_FAQ_BUTTON +import ch.admin.bag.dp3t.checkin.generateqrcode.EventOverviewItem.Companion.TYPE_FOOTER +import ch.admin.bag.dp3t.checkin.generateqrcode.EventOverviewItem.Companion.TYPE_GENERATE_QR_CODE_BUTTON +import ch.admin.bag.dp3t.databinding.* +import org.crowdnotifier.android.sdk.model.VenueInfo + +class QrCodeAdapter(private val onClickListener: OnClickListener) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun setItems(newItems: List) { + this.items = ArrayList().apply { addAll(newItems) } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QrCodeBaseViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_GENERATE_QR_CODE_BUTTON -> GenerateQrCodeViewHolder(ItemGenerateQrCodeBinding.inflate(inflater, parent, false)) + TYPE_EVENT -> EventViewHolder(ItemQrCodeBinding.inflate(inflater, parent, false)) + TYPE_EXPLANATION -> ExplanationViewHolder(ItemEventsExplanationBinding.inflate(inflater, parent, false)) + TYPE_FOOTER -> SimpleViewHolder(ItemEventsFooterBinding.inflate(inflater, parent, false)) + TYPE_FAQ_BUTTON -> FaqViewHolder(ItemFaqButtonBinding.inflate(inflater, parent, false)) + else -> throw IllegalArgumentException("invalid view type") + } + } + + override fun onBindViewHolder(holder: QrCodeBaseViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemViewType(position: Int) = items[position].type + + override fun getItemCount() = items.size + + + /*--------VIEW HOLDERS-------*/ + + abstract class QrCodeBaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + abstract fun bind(item: EventOverviewItem) + } + + inner class GenerateQrCodeViewHolder(private val binding: ItemGenerateQrCodeBinding) : QrCodeBaseViewHolder(binding.root) { + override fun bind(item: EventOverviewItem) { + binding.qrCodeGenerate.setOnClickListener { onClickListener.generateQrCode() } + } + + } + + inner class FaqViewHolder(private val binding: ItemFaqButtonBinding) : QrCodeBaseViewHolder(binding.root) { + override fun bind(item: EventOverviewItem) { + binding.faqButton.setOnClickListener { onClickListener.onFaqClicked() } + } + + } + + inner class EventViewHolder(private val binding: ItemQrCodeBinding) : QrCodeBaseViewHolder(binding.root) { + override fun bind(item: EventOverviewItem) { + item as EventItem + binding.apply { + qrCodeName.text = item.venueInfo.title + root.setOnClickListener { onClickListener.onQrCodeClicked(item.venueInfo) } + } + } + } + + inner class ExplanationViewHolder(private val binding: ItemEventsExplanationBinding) : QrCodeBaseViewHolder(binding.root) { + override fun bind(item: EventOverviewItem) { + item as ExplanationItem + binding.apply { + illu.isVisible = !item.showOnlyInfobox + title.isVisible = !item.showOnlyInfobox + subtitle.isVisible = !item.showOnlyInfobox + heading.isVisible = !item.showOnlyInfobox + generateQrCodeButton.isVisible = !item.showOnlyInfobox + generateQrCodeButton.setOnClickListener { onClickListener.generateQrCode() } + } + } + } + + inner class SimpleViewHolder(binding: ViewBinding) : QrCodeBaseViewHolder(binding.root) { + override fun bind(item: EventOverviewItem) {} + } +} + +interface OnClickListener { + fun generateQrCode() + fun onQrCodeClicked(qrCodeItem: VenueInfo) + fun onFaqClicked() +} + + diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeFragment.kt new file mode 100644 index 000000000..8a26ea46d --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeFragment.kt @@ -0,0 +1,120 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.print.PrintAttributes +import android.print.PrintManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.content.FileProvider +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.checkinflow.CheckInFragment +import ch.admin.bag.dp3t.checkin.models.CheckInState +import ch.admin.bag.dp3t.checkin.models.QRCodePayload +import ch.admin.bag.dp3t.databinding.FragmentQrCodeBinding +import ch.admin.bag.dp3t.extensions.* +import ch.admin.bag.dp3t.viewmodel.TracingViewModel +import com.google.protobuf.ByteString +import org.crowdnotifier.android.sdk.model.VenueInfo +import java.io.File + +private const val KEY_VENUE_INFO = "KEY_VENUE_INFO" + +class QrCodeFragment : Fragment() { + + companion object { + fun newInstance(venueInfo: VenueInfo) = QrCodeFragment().apply { + arguments = bundleOf(KEY_VENUE_INFO to venueInfo.toQrCodePayload().toByteString()) + } + } + + private val qrCodeViewModel: QRCodeViewModel by activityViewModels() + private val crowdNotifierViewModel: CrowdNotifierViewModel by activityViewModels() + private val tracingViewModel: TracingViewModel by activityViewModels() + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentQrCodeBinding.inflate(layoutInflater).apply { + cancelButton.setOnClickListener { requireActivity().supportFragmentManager.popBackStack() } + val venueInfo = QRCodePayload.parseFrom(arguments?.get(KEY_VENUE_INFO) as ByteString).toVenueInfo() + titleTextview.text = venueInfo.title + qrCodeImageview.visibility = View.INVISIBLE + qrCodeLoadingProgressbar.isVisible = true + shareButton.isEnabled = false + printPdfButton.isEnabled = false + qrCodeViewModel.selectedQrCodeBitmap.observe(viewLifecycleOwner) { + qrCodeImageview.visibility = View.VISIBLE + qrCodeLoadingProgressbar.isVisible = false + qrCodeImageview.setImageBitmap(it) + } + qrCodeViewModel.selectedQrCodePdf.observe(viewLifecycleOwner) { pdfFile -> + shareButton.isEnabled = true + printPdfButton.isEnabled = true + shareButton.setOnClickListener { sharePdf(pdfFile) } + printPdfButton.setOnClickListener { printPdf(pdfFile) } + } + qrCodeImageview.post { + qrCodeViewModel.generateQrCodeBitmapAndPdf(venueInfo, qrCodeImageview.width) + } + deleteButton.setOnClickListener { + showDeleteConfirmationDialog(venueInfo) + } + crowdNotifierViewModel.isCheckedIn.combineWith(tracingViewModel.appStatusLiveData) { isCheckedIn, appStatusInterface -> + // show self-checkin button only if user is not in isolation and is not checked in already + appStatusInterface?.isReportedAsInfected == false && isCheckedIn == false + }.observe(viewLifecycleOwner) { showCheckinButton -> + checkinButton.isVisible = showCheckinButton + } + checkinButton.setOnClickListener { showCheckInFragment(venueInfo) } + }.root + } + + private fun showCheckInFragment(venueInfo: VenueInfo) { + crowdNotifierViewModel.checkInState = + CheckInState(false, venueInfo, System.currentTimeMillis(), System.currentTimeMillis(), 0) + showFragment(CheckInFragment.newInstance(isSelfCheckin = true)) + } + + private fun printPdf(file: File) { + context?.let { + val manager = it.getSystemService(Context.PRINT_SERVICE) as PrintManager + val adapter = PdfPrintDocumentAdapter(file) + val attributes = PrintAttributes.Builder().build() + manager.print("SwissCovid QR Code", adapter, attributes) + } + } + + private fun sharePdf(file: File) { + context?.let { + val pdfUri: Uri = FileProvider.getUriForFile(it, it.applicationContext.packageName.toString() + ".provider", file) + + Intent().apply { + action = Intent.ACTION_SEND + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, pdfUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(this) + } + } + } + + private fun showDeleteConfirmationDialog(venueInfo: VenueInfo) { + AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.delete_qr_code_dialog) + .setPositiveButton(R.string.delete_button_title) { _, _ -> + qrCodeViewModel.deleteQrCode(venueInfo) + requireActivity().supportFragmentManager.popBackStack() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeItems.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeItems.kt new file mode 100644 index 000000000..ab2548def --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/generateqrcode/QrCodeItems.kt @@ -0,0 +1,36 @@ +package ch.admin.bag.dp3t.checkin.generateqrcode + +import org.crowdnotifier.android.sdk.model.VenueInfo + +abstract class EventOverviewItem { + companion object { + const val TYPE_EVENT = 0 + const val TYPE_EXPLANATION = 1 + const val TYPE_FOOTER = 2 + const val TYPE_GENERATE_QR_CODE_BUTTON = 3 + const val TYPE_FAQ_BUTTON = 4 + } + + abstract val type: Int +} + +class EventItem(val venueInfo: VenueInfo) : EventOverviewItem() { + override val type = TYPE_EVENT +} + +class ExplanationItem(val showOnlyInfobox: Boolean) : EventOverviewItem() { + override val type = TYPE_EXPLANATION +} + +class FooterItem : EventOverviewItem() { + override val type = TYPE_FOOTER +} + +class FaqButtonItem: EventOverviewItem() { + override val type = TYPE_FAQ_BUTTON +} + +class GenerateQrCodeButtonItem : EventOverviewItem() { + override val type = TYPE_GENERATE_QR_CODE_BUTTON +} + diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/models/CheckInState.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/CheckInState.kt new file mode 100644 index 000000000..ce305b81a --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/CheckInState.kt @@ -0,0 +1,13 @@ +package ch.admin.bag.dp3t.checkin.models + +import org.crowdnotifier.android.sdk.model.VenueInfo + +data class CheckInState( + var isCheckedIn: Boolean, + override val venueInfo: VenueInfo, + override var checkInTime: Long, + override var checkOutTime: Long, + var selectedReminderDelay: Long, +) : CheckinInfo { + override val id: Long? = null // must be null +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/models/CheckinInfo.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/CheckinInfo.kt new file mode 100644 index 000000000..9fbe21290 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/CheckinInfo.kt @@ -0,0 +1,10 @@ +package ch.admin.bag.dp3t.checkin.models + +import org.crowdnotifier.android.sdk.model.VenueInfo + +interface CheckinInfo { + val venueInfo: VenueInfo + var checkInTime: Long + var checkOutTime: Long + val id: Long? +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/models/DiaryEntry.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/DiaryEntry.java new file mode 100644 index 000000000..fbf36696f --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/DiaryEntry.java @@ -0,0 +1,54 @@ +package ch.admin.bag.dp3t.checkin.models; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; + +import org.crowdnotifier.android.sdk.model.VenueInfo; + +@Keep +public class DiaryEntry implements CheckinInfo { + + private long id; + private long arrivalTime; + private long departureTime; + private VenueInfo venueInfo; + + public DiaryEntry(long id, long arrivalTime, long departureTime, VenueInfo venueInfo) { + this.id = id; + this.arrivalTime = arrivalTime; + this.departureTime = departureTime; + this.venueInfo = venueInfo; + } + + @Override + @NonNull + public Long getId() { + return id; + } + + @Override + public long getCheckInTime() { + return arrivalTime; + } + + @Override + public long getCheckOutTime() { + return departureTime; + } + + @Override + public VenueInfo getVenueInfo() { + return venueInfo; + } + + @Override + public void setCheckInTime(long arrivalTime) { + this.arrivalTime = arrivalTime; + } + + @Override + public void setCheckOutTime(long departureTime) { + this.departureTime = departureTime; + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/models/ReminderOption.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/ReminderOption.kt new file mode 100644 index 000000000..56d65ce4a --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/models/ReminderOption.kt @@ -0,0 +1,18 @@ +package ch.admin.bag.dp3t.checkin.models + +import android.content.Context +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.util.StringUtil +import java.util.concurrent.TimeUnit + +class ReminderOption(val delayMillis: Long) { + + fun getDisplayString(context: Context): String { + return if (delayMillis < TimeUnit.MINUTES.toMillis(1)) { + context.getString(R.string.reminder_option_off) + } else { + StringUtil.getShortDurationStringWithUnits(delayMillis, context) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/CrowdNotifierKeyLoadWorker.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/CrowdNotifierKeyLoadWorker.java new file mode 100644 index 000000000..b5f8d46af --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/CrowdNotifierKeyLoadWorker.java @@ -0,0 +1,95 @@ +package ch.admin.bag.dp3t.checkin.networking; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.work.*; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.crowdnotifier.android.sdk.CrowdNotifier; +import org.crowdnotifier.android.sdk.model.ExposureEvent; +import org.crowdnotifier.android.sdk.model.ProblematicEventInfo; +import org.dpppt.android.sdk.DP3T; + +import ch.admin.bag.dp3t.BuildConfig; +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage; +import ch.admin.bag.dp3t.storage.SecureStorage; +import ch.admin.bag.dp3t.util.NotificationUtil; + +import static ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper.autoCheckoutIfNecessary; +import static org.dpppt.android.sdk.InfectionStatus.INFECTED; + + +public class CrowdNotifierKeyLoadWorker extends Worker { + + public static final String ACTION_NEW_TRACE_KEY_SYNC = BuildConfig.APPLICATION_ID + ".ACTION_NEW_TRACE_KEY_SYNC"; + private static final String WORK_TAG = "ch.admin.bag.dp3t.checkin.networking.CrowdNotifierKeyLoadWorker"; + private static final int DAYS_TO_KEEP_VENUE_VISITS = 14; + private static final int REPEAT_INTERVAL_MINUTES = 120; + private static final String LOG_TAG = "KeyLoadWorker"; + + public static void startKeyLoadWorker(Context context) { + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + PeriodicWorkRequest periodicWorkRequest = + new PeriodicWorkRequest.Builder(CrowdNotifierKeyLoadWorker.class, REPEAT_INTERVAL_MINUTES, TimeUnit.MINUTES) + .setConstraints(constraints) + .build(); + + WorkManager workManager = WorkManager.getInstance(context); + workManager.enqueueUniquePeriodicWork(WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, periodicWorkRequest); + } + + + public CrowdNotifierKeyLoadWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + Log.d(LOG_TAG, "Started KeyLoadWorker"); + if (DP3T.getStatus(getApplicationContext()).getInfectionStatus() == INFECTED) { + Log.d(LOG_TAG, "KeyLoadWorker: Network Request not executed"); + return Result.success(); + } + + List problematicEventInfos = new TraceKeysRepository(getApplicationContext()).loadTraceKeys(); + if (problematicEventInfos == null) { + Log.d(LOG_TAG, "KeyLoadWorker failure"); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(new Intent(ACTION_NEW_TRACE_KEY_SYNC)); + return Result.retry(); + } + List exposures = + CrowdNotifier.checkForMatches(problematicEventInfos, getApplicationContext()); + if (!exposures.isEmpty()) { + showExposureNotification(); + } + cleanUpOldData(getApplicationContext()); + autoCheckoutIfNecessary(getApplicationContext(), SecureStorage.getInstance(getApplicationContext()).getCheckInState()); + + SecureStorage.getInstance(getApplicationContext()).setLastSuccessfulCheckinDownload(System.currentTimeMillis()); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(new Intent(ACTION_NEW_TRACE_KEY_SYNC)); + Log.d(LOG_TAG, "KeyLoadWorker success"); + return Result.success(); + } + + private void showExposureNotification() { + SecureStorage secureStorage = SecureStorage.getInstance(getApplicationContext()); + NotificationUtil.generateContactNotification(getApplicationContext()); + secureStorage.setAppOpenAfterNotificationPending(true); + secureStorage.setReportsHeaderAnimationPending(true); + } + + public static void cleanUpOldData(Context context) { + CrowdNotifier.cleanUpOldData(context, DAYS_TO_KEEP_VENUE_VISITS); + DiaryStorage.getInstance(context).removeEntriesBefore(DAYS_TO_KEEP_VENUE_VISITS); + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/TraceKeysRepository.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/TraceKeysRepository.java new file mode 100644 index 000000000..a66829f52 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/TraceKeysRepository.java @@ -0,0 +1,112 @@ +package ch.admin.bag.dp3t.checkin.networking; + +import android.content.Context; + +import java.io.IOException; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.List; + +import org.crowdnotifier.android.sdk.model.DayDate; +import org.crowdnotifier.android.sdk.model.ProblematicEventInfo; +import org.dpppt.android.sdk.DP3T; +import org.dpppt.android.sdk.backend.SignatureVerificationInterceptor; +import org.dpppt.android.sdk.backend.UserAgentInterceptor; +import org.dpppt.android.sdk.util.SignatureUtil; +import org.jetbrains.annotations.NotNull; + +import ch.admin.bag.dp3t.BuildConfig; +import ch.admin.bag.dp3t.checkin.models.ProblematicEvent; +import ch.admin.bag.dp3t.checkin.models.ProblematicEventWrapper; +import ch.admin.bag.dp3t.storage.SecureStorage; +import okhttp3.OkHttpClient; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Response; +import retrofit2.Retrofit; + +public class TraceKeysRepository { + + private static final String KEY_BUNDLE_TAG_HEADER = "x-key-bundle-tag"; + + private TraceKeysService traceKeysService; + private SecureStorage storage; + + public TraceKeysRepository(Context context) { + + storage = SecureStorage.getInstance(context); + String baseUrl = BuildConfig.PUBLISHED_CROWDNOTIFIER_KEYS_BASE_URL; + + OkHttpClient.Builder okHttpBuilder = new OkHttpClient.Builder(); + okHttpBuilder.networkInterceptors().add(new UserAgentInterceptor(DP3T.getUserAgent())); + + PublicKey signaturePublicKey = SignatureUtil.getPublicKeyFromBase64OrThrow(BuildConfig.BUCKET_PUBLIC_KEY); + okHttpBuilder.addInterceptor(new SignatureVerificationInterceptor(signaturePublicKey)); + + + Retrofit bucketRetrofit = new Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpBuilder.build()) + .build(); + + traceKeysService = bucketRetrofit.create(TraceKeysService.class); + } + + public void loadTraceKeysAsync(Callback callback) { + traceKeysService.getTraceKeys(storage.getCrowdNotifierLastKeyBundleTag()).enqueue(new retrofit2.Callback() { + @Override + public void onResponse(@NotNull Call call, @NotNull Response response) { + if (response.isSuccessful()) { + callback.onTraceKeysLoaded(handleSuccessfulResponse(response)); + } else { + callback.onTraceKeysLoaded(null); + } + } + + @Override + public void onFailure(@NotNull Call call, @NotNull Throwable t) { + callback.onTraceKeysLoaded(null); + } + }); + } + + public List loadTraceKeys() { + try { + Response response = traceKeysService.getTraceKeys(storage.getCrowdNotifierLastKeyBundleTag()).execute(); + if (response.isSuccessful()) { + return handleSuccessfulResponse(response); + } + } catch (IOException e) { + return null; + } + return null; + } + + private List handleSuccessfulResponse(Response response) { + try { + String keyBundleTag = response.headers().get(KEY_BUNDLE_TAG_HEADER); + if (keyBundleTag != null) { + long keyBundleTagValue = Long.parseLong(keyBundleTag); + storage.setCrowdNotifierLastKeyBundleTag(keyBundleTagValue); + } + ProblematicEventWrapper problematicEventWrapper = ProblematicEventWrapper.parseFrom(response.body().byteStream()); + ArrayList problematicEventInfos = new ArrayList<>(); + for (ProblematicEvent event : problematicEventWrapper.getEventsList()) { + problematicEventInfos.add(new ProblematicEventInfo(event.getIdentity().toByteArray(), + event.getSecretKeyForIdentity().toByteArray(), + event.getEncryptedAssociatedData().toByteArray(), event.getCipherTextNonce().toByteArray(), + new DayDate(event.getDay() * 1000L)) + ); + } + return problematicEventInfos; + } catch (IOException e) { + return null; + } + } + + public interface Callback { + void onTraceKeysLoaded(List traceKeys); + + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/TraceKeysService.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/TraceKeysService.java new file mode 100644 index 000000000..d6fc1db61 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/TraceKeysService.java @@ -0,0 +1,15 @@ +package ch.admin.bag.dp3t.checkin.networking; + +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Headers; +import retrofit2.http.Query; + +public interface TraceKeysService { + + @Headers("Accept: application/x-protobuf") + @GET("v3/traceKeys") + Call getTraceKeys(@Query("lastKeyBundleTag") long lastKeyBundleTag); + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/UserUploadRepository.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/UserUploadRepository.kt new file mode 100644 index 000000000..26c971a7b --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/UserUploadRepository.kt @@ -0,0 +1,75 @@ +package ch.admin.bag.dp3t.checkin.networking + +import ch.admin.bag.dp3t.BuildConfig +import ch.admin.bag.dp3t.checkin.models.UploadVenueInfo +import ch.admin.bag.dp3t.checkin.models.UserUploadPayload +import com.google.protobuf.ByteString +import okhttp3.OkHttpClient +import org.dpppt.android.sdk.DP3T +import org.dpppt.android.sdk.backend.UserAgentInterceptor +import retrofit2.Retrofit +import retrofit2.converter.protobuf.ProtoConverterFactory +import java.util.* + +private const val USER_UPLOAD_SIZE = 1024 +private const val USER_UPLOAD_VERSION = 3 + +class UserUploadRepository { + + private var userUploadService: UserUploadService + private val random = Random() + + init { + val okHttpBuilder = OkHttpClient.Builder() + + okHttpBuilder.addInterceptor(UserAgentInterceptor(DP3T.getUserAgent())) + + val retrofit = Retrofit.Builder() + .baseUrl(BuildConfig.REPORT_URL) + .client(okHttpBuilder.build()) + .addConverterFactory(ProtoConverterFactory.create()) + .build() + userUploadService = retrofit.create(UserUploadService::class.java) + } + + suspend fun userUpload( + uploadVenueInfos: List, + timeBetweenOnsetAndUploadRequest: Int, + authorizationHeader: String + ) = + userUploadService.userUpload(getUserUploadPayload(uploadVenueInfos, timeBetweenOnsetAndUploadRequest), authorizationHeader) + + suspend fun fakeUserUpload(timeBetweenOnsetAndUploadRequest: Int, authorizationHeader: String) = + userUploadService.userUpload(getUserUploadPayload(listOf(), timeBetweenOnsetAndUploadRequest), authorizationHeader) + + private fun getUserUploadPayload( + uploadVenueInfos: List, + timeBetweenOnsetAndUploadRequest: Int + ): UserUploadPayload { + val userUploadPayloadBuilder = UserUploadPayload.newBuilder() + .setVersion(USER_UPLOAD_VERSION) + .setUserInteractionDurationMs(timeBetweenOnsetAndUploadRequest) + userUploadPayloadBuilder.addAllVenueInfos(uploadVenueInfos) + for (i in userUploadPayloadBuilder.venueInfosCount until USER_UPLOAD_SIZE) { + userUploadPayloadBuilder.addVenueInfos(getRandomFakeVenueInfo()) + } + return userUploadPayloadBuilder.build() + } + + private fun getRandomFakeVenueInfo(): UploadVenueInfo { + return UploadVenueInfo.newBuilder() + .setPreId(ByteString.copyFrom(getRandomByteArray(32))) + .setTimeKey(ByteString.copyFrom(getRandomByteArray(32))) + .setNotificationKey(ByteString.copyFrom(getRandomByteArray(32))) + .setIntervalStartMs(random.nextLong()) + .setIntervalEndMs(random.nextLong()) + .setFake(ByteString.copyFrom(ByteArray(1) { 1 })) + .build() + } + + private fun getRandomByteArray(size: Int): ByteArray { + return ByteArray(size).apply { + random.nextBytes(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/UserUploadService.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/UserUploadService.kt new file mode 100644 index 000000000..810c4cf08 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/networking/UserUploadService.kt @@ -0,0 +1,15 @@ +package ch.admin.bag.dp3t.checkin.networking + +import ch.admin.bag.dp3t.checkin.models.UserUploadPayload +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +interface UserUploadService { + + @POST("v3/userupload") + suspend fun userUpload( + @Body userUploadPayload: UserUploadPayload, @Header("Authorization") authorizationHeader: String? + ) + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/startup/BootCompletedReceiver.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/startup/BootCompletedReceiver.kt new file mode 100644 index 000000000..411749ff1 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/startup/BootCompletedReceiver.kt @@ -0,0 +1,32 @@ +package ch.admin.bag.dp3t.checkin.startup + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import ch.admin.bag.dp3t.checkin.utils.CrowdNotifierReminderHelper +import ch.admin.bag.dp3t.checkin.utils.NotificationHelper +import ch.admin.bag.dp3t.extensions.getAutoCheckoutDelay +import ch.admin.bag.dp3t.extensions.getCheckoutWarningDelay +import ch.admin.bag.dp3t.storage.SecureStorage + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (Intent.ACTION_BOOT_COMPLETED != intent.action) { + return + } + invalidateCheckIn(context) + } + + private fun invalidateCheckIn(context: Context) { + val storage = SecureStorage.getInstance(context) + val checkInState = storage.checkInState ?: return + val checkedOut = CrowdNotifierReminderHelper.autoCheckoutIfNecessary(context, checkInState) + if (!checkedOut) { + val checkInTime = checkInState.checkInTime + NotificationHelper.getInstance(context).startOngoingNotification(checkInTime, checkInState.venueInfo) + CrowdNotifierReminderHelper.setCheckoutWarning(checkInTime, checkInState.venueInfo.getCheckoutWarningDelay(), context) + CrowdNotifierReminderHelper.setAutoCheckOut(checkInTime, checkInState.venueInfo.getAutoCheckoutDelay(), context) + CrowdNotifierReminderHelper.setReminder(checkInTime + checkInState.selectedReminderDelay, context) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/storage/DiaryStorage.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/storage/DiaryStorage.java new file mode 100644 index 000000000..576ca52e2 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/storage/DiaryStorage.java @@ -0,0 +1,123 @@ +package ch.admin.bag.dp3t.checkin.storage; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKeys; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import org.crowdnotifier.android.sdk.model.DayDate; + +import ch.admin.bag.dp3t.checkin.models.DiaryEntry; + +public class DiaryStorage { + + private static final String KEY_DIARY_STORE = "KEY_DIARY_STORE"; + private static final String KEY_DIARY_ENTRIES_V3 = "KEY_DIARY_ENTRIES_V3"; + private static final Type EXPOSURE_LIST_V3_TYPE = new TypeToken>() { }.getType(); + + + private static DiaryStorage instance; + + private SharedPreferences sharedPreferences; + private final Gson gson = new Gson(); + + private DiaryStorage(Context context) { + try { + String KEY_ALIAS = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); + sharedPreferences = EncryptedSharedPreferences.create(KEY_DIARY_STORE, + KEY_ALIAS, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM); + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e); + } + } + + public static synchronized DiaryStorage getInstance(Context context) { + if (instance == null) { + instance = new DiaryStorage(context); + } + return instance; + } + + public boolean addEntry(DiaryEntry diaryEntry) { + List diaryEntries = getEntries(); + if (hasExposureWithId(diaryEntry.getId())) return false; + diaryEntries.add(diaryEntry); + saveToPrefs(diaryEntries); + return true; + } + + public boolean updateEntry(DiaryEntry newDiaryEntry) { + List diaryEntries = getEntries(); + DiaryEntry oldDiaryEntry = getDiaryEntryWithId(diaryEntries, newDiaryEntry.getId()); + if (oldDiaryEntry == null) return false; + diaryEntries.remove(oldDiaryEntry); + diaryEntries.add(newDiaryEntry); + saveToPrefs(diaryEntries); + return true; + } + + public boolean removeEntry(long id) { + List diaryEntries = getEntries(); + DiaryEntry diaryEntry = getDiaryEntryWithId(diaryEntries, id); + if (diaryEntry == null) return false; + diaryEntries.remove(diaryEntry); + saveToPrefs(diaryEntries); + return true; + } + + public boolean hasExposureWithId(long id) { + return getDiaryEntryWithId(id) != null; + } + + public DiaryEntry getDiaryEntryWithId(long id) { + return getDiaryEntryWithId(getEntries(), id); + } + + private DiaryEntry getDiaryEntryWithId(List diaryEntries, long id) { + for (DiaryEntry exposure : diaryEntries) { + if (exposure.getId() == id) { + return exposure; + } + } + return null; + } + + public List getEntries() { + return gson.fromJson(sharedPreferences.getString(KEY_DIARY_ENTRIES_V3, "[]"), EXPOSURE_LIST_V3_TYPE); + } + + + public void removeEntriesBefore(int maxDaysToKeep) { + List exposureList = getEntries(); + DayDate lastDateToKeep = new DayDate().subtractDays(maxDaysToKeep); + Iterator iterator = exposureList.iterator(); + while (iterator.hasNext()) { + if (new DayDate(iterator.next().getCheckOutTime()).isBefore(lastDateToKeep)) { + iterator.remove(); + } + } + saveToPrefs(exposureList); + } + + private void saveToPrefs(List diaryEntries) { + sharedPreferences.edit().putString(KEY_DIARY_ENTRIES_V3, gson.toJson(diaryEntries)).apply(); + } + + public void clear() { + saveToPrefs(new ArrayList<>()); + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/CrowdNotifierReminderHelper.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/CrowdNotifierReminderHelper.java new file mode 100644 index 000000000..44871950e --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/CrowdNotifierReminderHelper.java @@ -0,0 +1,156 @@ +package ch.admin.bag.dp3t.checkin.utils; + + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import java.util.Collections; +import java.util.List; + +import org.crowdnotifier.android.sdk.CrowdNotifier; + +import ch.admin.bag.dp3t.BuildConfig; +import ch.admin.bag.dp3t.checkin.models.CheckInState; +import ch.admin.bag.dp3t.checkin.models.DiaryEntry; +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage; +import ch.admin.bag.dp3t.extensions.VenueInfoExtensionsKt; +import ch.admin.bag.dp3t.storage.SecureStorage; + +public class CrowdNotifierReminderHelper extends BroadcastReceiver { + + public static final String ACTION_DID_AUTO_CHECKOUT = BuildConfig.APPLICATION_ID + ".ACTION_DID_AUTO_CHECKOUT"; + private static final int REMINDER_INTENT_ID = 12; + private static final String ACTION_REMINDER = BuildConfig.APPLICATION_ID + ".ACTION_REMINDER"; + private static final int CHECKOUT_WARNING_INTENT_ID = 13; + private static final String ACTION_CHECKOUT_WARNING = BuildConfig.APPLICATION_ID + ".ACTION_CHECKOUT_WARNING"; + private static final int AUTO_CHECK_OUT_INTENT_ID = 14; + private static final String ACTION_AUTO_CHECKOUT = BuildConfig.APPLICATION_ID + ".ACTION_AUTO_CHECKOUT"; + + public static void removeAllReminders(Context context) { + removeReminder(context); + removeCheckoutWarning(context); + removeAutoCheckOut(context); + } + + public static void removeReminder(Context context) { + PendingIntent pendingIntent = getPendingIntent(context, false); + removeReminder(pendingIntent, context); + } + + public static void setReminder(long alarmTime, Context context) { + PendingIntent pendingIntent = getPendingIntent(context, false); + setReminder(alarmTime, pendingIntent, context); + } + + public static void setCheckoutWarning(long checkInTime, long delay, Context context) { + PendingIntent pendingIntent = getPendingIntent(context, true); + setReminder(checkInTime + delay, pendingIntent, context); + } + + public static void removeCheckoutWarning(Context context) { + PendingIntent pendingIntent = getPendingIntent(context, true); + removeReminder(pendingIntent, context); + } + + public static void setAutoCheckOut(long checkInTime, long delay, Context context) { + PendingIntent pendingIntent = getAutoCheckOutPendingIntent(context); + setReminder(checkInTime + delay, pendingIntent, context); + } + + public static void removeAutoCheckOut(Context context) { + PendingIntent pendingIntent = getAutoCheckOutPendingIntent(context); + removeReminder(pendingIntent, context); + } + + private static void setReminder(long alarmTime, PendingIntent pendingIntent, Context context) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmTime <= System.currentTimeMillis()) { + alarmManager.cancel(pendingIntent); + return; + } + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, alarmTime, pendingIntent); + } + + private static void removeReminder(PendingIntent pendingIntent, Context context) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(pendingIntent); + } + + private static PendingIntent getPendingIntent(Context context, boolean eightHours) { + Intent intent = new Intent(context, CrowdNotifierReminderHelper.class); + if (eightHours) { + intent.setAction(ACTION_CHECKOUT_WARNING); + return PendingIntent.getBroadcast(context, CHECKOUT_WARNING_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } else { + intent.setAction(ACTION_REMINDER); + return PendingIntent.getBroadcast(context, REMINDER_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + } + + private static PendingIntent getAutoCheckOutPendingIntent(Context context) { + Intent intent = new Intent(context, CrowdNotifierReminderHelper.class); + intent.setAction(ACTION_AUTO_CHECKOUT); + return PendingIntent.getBroadcast(context, AUTO_CHECK_OUT_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + @Override + public void onReceive(Context context, Intent intent) { + CheckInState checkInState = SecureStorage.getInstance(context).getCheckInState(); + if (ACTION_REMINDER.equals(intent.getAction()) || + ACTION_CHECKOUT_WARNING.equals(intent.getAction()) && checkInState != null) { + NotificationHelper.getInstance(context).showReminderNotification(); + } else if (ACTION_AUTO_CHECKOUT.equals(intent.getAction())) { + autoCheckoutIfNecessary(context, checkInState); + } + } + + public static boolean autoCheckoutIfNecessary(Context context, CheckInState checkInState) { + if (checkInState == null) { + return false; + } + long autoCheckoutDelay = VenueInfoExtensionsKt.getAutoCheckoutDelay(checkInState.getVenueInfo()); + if (checkInState.getCheckInTime() > System.currentTimeMillis() - autoCheckoutDelay) { + return false; + } + + NotificationHelper notificationHelper = NotificationHelper.getInstance(context); + notificationHelper.stopOngoingNotification(); + notificationHelper.removeReminderNotification(); + notificationHelper.showAutoCheckoutNotification(); + long checkIn = checkInState.getCheckInTime(); + long checkOut = checkIn + autoCheckoutDelay; + + DiaryStorage diaryStorage = DiaryStorage.getInstance(context); + List existingDiaryEntries = diaryStorage.getEntries(); + Collections.sort(existingDiaryEntries, (a, b) -> Long.compare(a.getCheckInTime(), b.getCheckInTime())); + for (DiaryEntry existingEntry : existingDiaryEntries) { + if (existingEntry.getCheckInTime() < checkOut && existingEntry.getCheckOutTime() > checkIn) { + long beforeEntryCheckin = checkIn; + long beforeEntryCheckout = existingEntry.getCheckInTime(); + if (beforeEntryCheckin < beforeEntryCheckout) { + long id = + CrowdNotifier.addCheckIn(beforeEntryCheckin, beforeEntryCheckout, checkInState.getVenueInfo(), + context); + diaryStorage.addEntry(new DiaryEntry(id, beforeEntryCheckin, beforeEntryCheckout, + checkInState.getVenueInfo())); + } + checkIn = existingEntry.getCheckOutTime(); + } + } + + if (checkIn < checkOut) { + long id = CrowdNotifier.addCheckIn(checkIn, checkOut, checkInState.getVenueInfo(), context); + diaryStorage.addEntry(new DiaryEntry(id, checkIn, checkOut, checkInState.getVenueInfo())); + } + + SecureStorage storage = SecureStorage.getInstance(context); + storage.setCheckInState(null); + LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent(ACTION_DID_AUTO_CHECKOUT)); + return true; + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/ErrorDialog.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/ErrorDialog.java new file mode 100644 index 000000000..704658b0a --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/ErrorDialog.java @@ -0,0 +1,37 @@ +package ch.admin.bag.dp3t.checkin.utils; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState; + +public class ErrorDialog extends AlertDialog { + + CrowdNotifierErrorState errorState; + + public ErrorDialog(@NonNull Context context, CrowdNotifierErrorState errorState) { + super(context); + this.errorState = errorState; + } + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.dialog_error); + findViewById(R.id.dialog_error_container).setBackgroundResource(R.color.white); + View closeButton = findViewById(R.id.dialog_error_close_button); + + closeButton.setOnClickListener(v -> dismiss()); + ErrorHelper.updateErrorView(findViewById(R.id.dialog_error_container), errorState, this::dismiss, getContext()); + + getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + getWindow().setBackgroundDrawableResource(R.drawable.dialog_background); + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/NotificationHelper.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/NotificationHelper.java new file mode 100644 index 000000000..1d4f1ab55 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/NotificationHelper.java @@ -0,0 +1,149 @@ +package ch.admin.bag.dp3t.checkin.utils; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import androidx.core.app.NotificationCompat; +import androidx.core.app.TaskStackBuilder; + +import org.crowdnotifier.android.sdk.model.VenueInfo; + +import ch.admin.bag.dp3t.BuildConfig; +import ch.admin.bag.dp3t.MainActivity; +import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.util.StringUtil; + +import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH; +import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW; + +public class NotificationHelper { + + public static final String ACTION_CROWDNOTIFIER_REMINDER_NOTIFICATION = BuildConfig.APPLICATION_ID + + ".ACTION_CROWDNOTIFIER_REMINDER_NOTIFICATION"; + public static final String ACTION_AUTO_CHECKOUT_NOTIFICATION = BuildConfig.APPLICATION_ID + + ".ACTION_AUTO_CHECKOUT_NOTIFICATION"; + public static final String ACTION_ONGOING_NOTIFICATION = BuildConfig.APPLICATION_ID + ".ACTION_ONGOING_NOTIFICATION"; + public static final String ACTION_CHECK_OUT_NOW = BuildConfig.APPLICATION_ID + ".ACTION_CHECK_OUT_NOW"; + public static final String ACTION_SNOOZE = BuildConfig.APPLICATION_ID + ".ACTION_SNOOZE"; + + private final String CHANNEL_ID_REMINDER = "Reminders"; + private final String CHANNEL_ID_ONGOING_CHECK_IN = "Ongoing Check In"; + + private final int ONGOING_NOTIFICATION_ID = -1; + private final int REMINDER_NOTIFICATION_ID = -2; + private final int AUTO_CHECKOUT_NOTIFICATION_ID = -3; + + + private static NotificationHelper instance; + + private Context context; + private NotificationManager notificationManager; + + private NotificationHelper(Context context) { + this.context = context; + this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + public static synchronized NotificationHelper getInstance(Context context) { + if (instance == null) { + instance = new NotificationHelper(context); + } + return instance; + } + + private void createNotificationChannel(String channelId, String channelName, boolean silent, int importance) { + if (Build.VERSION.SDK_INT >= 26) { + NotificationChannel channel = new NotificationChannel(channelId, channelName, importance); + if (silent) { + channel.setSound(null, null); + channel.enableVibration(false); + } + notificationManager.createNotificationChannel(channel); + } + } + + private PendingIntent createBasicPendingIntent(String notificationAction) { + Intent intent = new Intent(context, MainActivity.class); + intent.setAction(notificationAction); + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(intent) + .getPendingIntent(notificationAction.hashCode(), PendingIntent.FLAG_UPDATE_CURRENT); + } + + private NotificationCompat.Builder getNotificationBuilder(String channelId) { + return new NotificationCompat.Builder(context, channelId) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(Notification.DEFAULT_ALL); + } + + public void showAutoCheckoutNotification() { + createNotificationChannel(CHANNEL_ID_REMINDER, context.getString(R.string.android_reminder_channel_name), false, + IMPORTANCE_HIGH); + + Notification notification = getNotificationBuilder(CHANNEL_ID_REMINDER) + .setContentIntent(createBasicPendingIntent(ACTION_AUTO_CHECKOUT_NOTIFICATION)) + .setContentTitle(context.getString(R.string.auto_checkout_title)) + .setContentText(context.getString(R.string.auto_checkout_body)) + .build(); + + notificationManager.notify(AUTO_CHECKOUT_NOTIFICATION_ID, notification); + } + + public void showReminderNotification() { + + createNotificationChannel(CHANNEL_ID_REMINDER, context.getString(R.string.android_reminder_channel_name), false, + IMPORTANCE_HIGH); + + Intent snoozeIntent = new Intent(context, NotificationQuickActionReceiver.class); + snoozeIntent.setAction(ACTION_SNOOZE); + PendingIntent snoozePendingIntent = PendingIntent.getBroadcast(context, 1, snoozeIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + Notification notification = getNotificationBuilder(CHANNEL_ID_REMINDER) + .setContentIntent(createBasicPendingIntent(ACTION_CROWDNOTIFIER_REMINDER_NOTIFICATION)) + .setContentTitle(context.getString(R.string.checkout_reminder_title)) + .setContentText(context.getString(R.string.checkout_reminder_text)) + .addAction(R.drawable.ic_close, + context.getString(R.string.ongoing_notification_checkout_quick_action), + createBasicPendingIntent(ACTION_CHECK_OUT_NOW)) + .addAction(R.drawable.ic_snooze, + context.getString(R.string.reminder_notification_snooze_action), + snoozePendingIntent) + .build(); + + notificationManager.notify(REMINDER_NOTIFICATION_ID, notification); + } + + public void removeReminderNotification() { + notificationManager.cancel(REMINDER_NOTIFICATION_ID); + } + + public void startOngoingNotification(long startTime, VenueInfo venueInfo) { + createNotificationChannel(CHANNEL_ID_ONGOING_CHECK_IN, + context.getString(R.string.android_ongoing_checkin_notification_channel_name), true, IMPORTANCE_LOW); + Notification ongoingNotification = new NotificationCompat.Builder(context, CHANNEL_ID_ONGOING_CHECK_IN) + .setSmallIcon(R.drawable.ic_qr_small) + .setContentTitle(context.getString(R.string.ongoing_notification_title) + .replace("{TIME}", StringUtil.getHourMinuteTimeString(startTime, ":"))) + .setContentText(venueInfo.getTitle()) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .addAction(R.drawable.ic_close, + context.getString(R.string.ongoing_notification_checkout_quick_action), + createBasicPendingIntent(ACTION_CHECK_OUT_NOW)) + .setContentIntent(createBasicPendingIntent(ACTION_ONGOING_NOTIFICATION)) + .build(); + notificationManager.notify(ONGOING_NOTIFICATION_ID, ongoingNotification); + } + + public void stopOngoingNotification() { + notificationManager.cancel(ONGOING_NOTIFICATION_ID); + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/NotificationQuickActionReceiver.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/NotificationQuickActionReceiver.java new file mode 100644 index 000000000..0dd2285b2 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/NotificationQuickActionReceiver.java @@ -0,0 +1,21 @@ +package ch.admin.bag.dp3t.checkin.utils; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import static ch.admin.bag.dp3t.checkin.utils.NotificationHelper.ACTION_SNOOZE; + +public class NotificationQuickActionReceiver extends BroadcastReceiver { + + private final long SNOOZE_DURATION = 1000L * 60 * 30; // 30 minutes + + @Override + public void onReceive(Context context, Intent intent) { + if (ACTION_SNOOZE.equals(intent.getAction())) { + NotificationHelper.getInstance(context).removeReminderNotification(); + CrowdNotifierReminderHelper.setReminder(System.currentTimeMillis() + SNOOZE_DURATION, context); + } + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/SingleLiveEvent.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/SingleLiveEvent.kt new file mode 100644 index 000000000..7bbb652c1 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/utils/SingleLiveEvent.kt @@ -0,0 +1,58 @@ +package ch.admin.bag.dp3t.checkin.utils + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import java.util.concurrent.atomic.AtomicBoolean + +private const val TAG = "SingleLiveEvent" + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes. + * + * Inspired by https://github.com/android/architecture-samples/blob/dev-todo-mvvm-live/todoapp/app/src/main/java/com/example/android/architecture/blueprints/todoapp/SingleLiveEvent.java + */ +class SingleLiveEvent : MutableLiveData() { + + // flag to avoid calling Observer's `onChanged()` when it's registered + private val mPending = AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + + // Observe the internal MutableLiveData + super.observe(owner, { t -> + if (mPending.compareAndSet(true, false)) { + observer.onChanged(t) + } + }) + } + + @MainThread + override fun setValue(t: T) { + mPending.set(true) + super.setValue(t) + } +} + +/** + * Used for cases where T is Unit, to make calls cleaner. + */ +@MainThread +fun SingleLiveEvent.call() { + setValue(null) +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/contacts/HistoryViewHolder.java b/app/src/main/java/ch/admin/bag/dp3t/contacts/HistoryViewHolder.java index 54e3c1be3..14683dcb7 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/contacts/HistoryViewHolder.java +++ b/app/src/main/java/ch/admin/bag/dp3t/contacts/HistoryViewHolder.java @@ -52,6 +52,8 @@ private static int getLabelForType(HistoryEntryType type) { return R.string.synchronizations_view_sync_via_fake_request; case NEXT_DAY_KEY_UPLOAD_REQUEST: return R.string.synchronizations_view_sync_via_next_day_key_upload; + case NOTIFICATION: + return R.string.synchronizations_view_notification; default: throw new IllegalArgumentException("Unknown HistoryEntryType: " + type); } diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/ExposureEventExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/ExposureEventExtensions.kt new file mode 100644 index 000000000..f551464f0 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/ExposureEventExtensions.kt @@ -0,0 +1,12 @@ +package ch.admin.bag.dp3t.extensions + +import android.content.Context +import ch.admin.bag.dp3t.util.StringUtil +import org.crowdnotifier.android.sdk.model.ExposureEvent + +fun ExposureEvent.getDetailsString(context: Context): String { + val dateString = StringUtil.getReportDateString(endTime, true, false, context) + val startTimeString = StringUtil.getHourMinuteTimeString(startTime, ":") + val endTimeString = StringUtil.getHourMinuteTimeString(endTime, ":") + return "$dateString\n$startTimeString - $endTimeString" +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/FragmentExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/FragmentExtensions.kt new file mode 100644 index 000000000..432a426f3 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/FragmentExtensions.kt @@ -0,0 +1,25 @@ +package ch.admin.bag.dp3t.extensions + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import ch.admin.bag.dp3t.R + +fun Fragment.showFragment( + fragment: Fragment, + @IdRes container: Int = R.id.main_fragment_container, + modalAnimation: Boolean = false +) { + requireActivity().supportFragmentManager.beginTransaction() + .apply { + if (modalAnimation) { + this.setCustomAnimations( + R.anim.modal_slide_enter, R.anim.modal_slide_exit, R.anim.modal_pop_enter, R.anim.modal_pop_exit + ) + } else { + this.setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) + } + } + .addToBackStack(fragment::class.java.canonicalName) + .replace(container, fragment) + .commit() +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/LiveDataExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/LiveDataExtensions.kt new file mode 100644 index 000000000..2ccd23a2b --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/LiveDataExtensions.kt @@ -0,0 +1,11 @@ +package ch.admin.bag.dp3t.extensions + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +fun LiveData.combineWith(liveData: LiveData, block: (T?, K?) -> R): LiveData { + val result = MediatorLiveData() + result.addSource(this) { result.value = block(this.value, liveData.value) } + result.addSource(liveData) { result.value = block(this.value, liveData.value) } + return result +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/PackageManagerExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/PackageManagerExtensions.kt new file mode 100644 index 000000000..d007ad8d5 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/PackageManagerExtensions.kt @@ -0,0 +1,12 @@ +package ch.admin.bag.dp3t.extensions + +import android.content.pm.PackageManager + +fun PackageManager.isPackageInstalled(packageName: String): Boolean { + return try { + getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/QrCodePayloadExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/QrCodePayloadExtensions.kt new file mode 100644 index 000000000..c5fa7fc2d --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/QrCodePayloadExtensions.kt @@ -0,0 +1,10 @@ +package ch.admin.bag.dp3t.extensions + +import ch.admin.bag.dp3t.checkin.models.QRCodePayload +import org.crowdnotifier.android.sdk.model.VenueInfo +import org.crowdnotifier.android.sdk.utils.Base64Util +import org.crowdnotifier.android.sdk.utils.QrUtils + +fun QRCodePayload.toVenueInfo(): VenueInfo { + return QrUtils.getVenueInfoFromQrCode(Base64Util.toBase64(this.toByteArray())) +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/VenueInfoExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/VenueInfoExtensions.kt new file mode 100644 index 000000000..6483f00f7 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/VenueInfoExtensions.kt @@ -0,0 +1,38 @@ +package ch.admin.bag.dp3t.extensions + +import android.content.Context +import ch.admin.bag.dp3t.checkin.models.QRCodePayload +import ch.admin.bag.dp3t.checkin.models.ReminderOption +import com.google.protobuf.InvalidProtocolBufferException +import org.crowdnotifier.android.sdk.model.VenueInfo + +private const val ONE_MINUTE_IN_MILLIS = 60 * 1000L + +fun VenueInfo.toQrCodePayload(): QRCodePayload { + try { + return QRCodePayload.parseFrom(qrCodePayload) + } catch (e: InvalidProtocolBufferException) { + throw RuntimeException("VenueInfo contains invalid qrCodePayload bytes!") + } +} + +fun VenueInfo.getAutoCheckoutDelay() = getSwissCovidLocationData().automaticCheckoutDelaylMs + +fun VenueInfo.getCheckoutWarningDelay() = getSwissCovidLocationData().checkoutWarningDelayMs + +fun VenueInfo.getReminderDelayOptions(context: Context): List { + val filteredResult = getSwissCovidLocationData().reminderDelayOptionsMsList.asSequence() + .filter { it >= ONE_MINUTE_IN_MILLIS && it < getAutoCheckoutDelay() } + .sorted() + .map { ReminderOption(it) } + .distinctBy { it.getDisplayString(context) } + .take(3) + .toList() + return if (filteredResult.isEmpty()) { + //Fallback if reminderDelayOptionsMsList is empty (30, 60, and 120 minutes) + listOf(30, 60, 120).map { ReminderOption(it * ONE_MINUTE_IN_MILLIS) } + } else { + filteredResult + } +} + diff --git a/app/src/main/java/ch/admin/bag/dp3t/home/HomeFragment.java b/app/src/main/java/ch/admin/bag/dp3t/home/HomeFragment.java index 544497b9f..d718e2029 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/home/HomeFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/home/HomeFragment.java @@ -11,11 +11,11 @@ import android.animation.Animator; import android.animation.AnimatorListenerAdapter; -import android.app.Activity; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; +import android.content.res.ColorStateList; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -23,43 +23,47 @@ import android.text.TextUtils; import android.util.TypedValue; import android.view.View; +import android.widget.Button; import android.widget.ImageView; import android.widget.ScrollView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.constraintlayout.helper.widget.Flow; import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.res.ResourcesCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; -import java.util.List; import java.util.concurrent.TimeUnit; import org.dpppt.android.sdk.TracingStatus; -import org.dpppt.android.sdk.internal.logger.Logger; import ch.admin.bag.dp3t.BuildConfig; import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.CheckinOverviewFragment; +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel; +import ch.admin.bag.dp3t.checkin.checkinflow.CheckOutFragment; +import ch.admin.bag.dp3t.checkin.checkinflow.QrCodeScannerFragment; +import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState; import ch.admin.bag.dp3t.contacts.ContactsFragment; +import ch.admin.bag.dp3t.extensions.FragmentExtensionsKt; import ch.admin.bag.dp3t.home.model.NotificationState; import ch.admin.bag.dp3t.home.model.NotificationStateError; import ch.admin.bag.dp3t.home.model.TracingState; import ch.admin.bag.dp3t.home.model.TracingStatusInterface; import ch.admin.bag.dp3t.home.views.HeaderView; +import ch.admin.bag.dp3t.inform.InformActivity; import ch.admin.bag.dp3t.networking.models.InfoBoxModel; import ch.admin.bag.dp3t.networking.models.InfoBoxModelCollection; import ch.admin.bag.dp3t.reports.ReportsFragment; +import ch.admin.bag.dp3t.reports.ReportsOverviewFragment; import ch.admin.bag.dp3t.storage.SecureStorage; -import ch.admin.bag.dp3t.travel.TravelFragment; -import ch.admin.bag.dp3t.travel.TravelUtils; import ch.admin.bag.dp3t.util.*; import ch.admin.bag.dp3t.viewmodel.TracingViewModel; import ch.admin.bag.dp3t.whattodo.WtdInfolineAccessabilityDialogFragment; -import ch.admin.bag.dp3t.whattodo.WtdPositiveTestFragment; -import ch.admin.bag.dp3t.whattodo.WtdSymptomsFragment; import static android.view.View.VISIBLE; @@ -67,6 +71,7 @@ public class HomeFragment extends Fragment { private static final String TAG = "HomeFragment"; private TracingViewModel tracingViewModel; + private CrowdNotifierViewModel crowdNotifierViewModel; private HeaderView headerView; private ScrollView scrollView; @@ -76,12 +81,9 @@ public class HomeFragment extends Fragment { private View reportStatusBubble; private View reportStatusView; private View reportErrorView; - private View travelCard; - private View cardSymptomsFrame; - private View cardTestFrame; - private View cardSymptoms; - private View cardTest; + private View checkinCard; private View loadingView; + private View covidCodeCard; private SecureStorage secureStorage; @@ -100,6 +102,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) { secureStorage = SecureStorage.getInstance(getContext()); tracingViewModel = new ViewModelProvider(requireActivity()).get(TracingViewModel.class); + crowdNotifierViewModel = new ViewModelProvider(requireActivity()).get(CrowdNotifierViewModel.class); getChildFragmentManager() .beginTransaction() @@ -115,23 +118,20 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat reportStatusBubble = view.findViewById(R.id.report_status_bubble); reportStatusView = reportStatusBubble.findViewById(R.id.report_status); reportErrorView = reportStatusBubble.findViewById(R.id.report_errors); - travelCard = view.findViewById(R.id.card_travel); + checkinCard = view.findViewById(R.id.card_checkin); headerView = view.findViewById(R.id.home_header_view); scrollView = view.findViewById(R.id.home_scroll_view); - cardSymptoms = view.findViewById(R.id.card_what_to_do_symptoms); - cardSymptomsFrame = view.findViewById(R.id.frame_card_symptoms); - cardTest = view.findViewById(R.id.card_what_to_do_test); - cardTestFrame = view.findViewById(R.id.frame_card_test); loadingView = view.findViewById(R.id.loading_view); + covidCodeCard = view.findViewById(R.id.card_covidcode); setupHeader(); setupInfobox(); setupTracingView(); setupNotification(); - setupTravelCard(); - setupWhatToDo(); + setupCheckinCard(); setupNonProductionHint(); setupScrollBehavior(); + setupCovidCodeCard(); showEndIsolationDialogIfNecessary(); } @@ -231,20 +231,15 @@ private void setupInfobox() { private void setupTracingView() { TypedValue outValue = new TypedValue(); - requireContext().getTheme().resolveAttribute( - android.R.attr.selectableItemBackground, outValue, true); + requireContext().getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); tracingCard.setForeground(requireContext().getDrawable(outValue.resourceId)); tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> { - if (tracingStatusInterface.isReportedAsInfected()) { - cardSymptomsFrame.setVisibility(View.GONE); - cardTestFrame.setVisibility(View.GONE); + if (tracingStatusInterface.isReportedAsInfected() || secureStorage.getOnlyPartialOnboardingCompleted()) { tracingCard.findViewById(R.id.contacs_chevron).setVisibility(View.GONE); tracingCard.setOnClickListener(null); tracingCard.setForeground(null); } else { - cardSymptomsFrame.setVisibility(VISIBLE); - cardTestFrame.setVisibility(VISIBLE); tracingCard.findViewById(R.id.contacs_chevron).setVisibility(VISIBLE); tracingCard.setOnClickListener(v -> showContactsFragment()); } @@ -252,7 +247,7 @@ private void setupTracingView() { } private void showContactsFragment() { - getActivity().getSupportFragmentManager().beginTransaction() + requireActivity().getSupportFragmentManager().beginTransaction() .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) .replace(R.id.main_fragment_container, ContactsFragment.newInstance()) .addToBackStack(ContactsFragment.class.getCanonicalName()) @@ -260,73 +255,94 @@ private void showContactsFragment() { } private void showReportsFragment() { - getActivity().getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) - .replace(R.id.main_fragment_container, ReportsFragment.newInstance()) - .addToBackStack(ReportsFragment.class.getCanonicalName()) - .commit(); + int checkinReports = crowdNotifierViewModel.getExposures().getValue().size(); + int tracingReports = tracingViewModel.getAppStatusLiveData().getValue().getExposureDays().size(); + boolean isReportedPositive = tracingViewModel.getTracingStatusInterface().isReportedAsInfected(); + if (((checkinReports > 0 && tracingReports > 0) || checkinReports > 1) && !isReportedPositive) { + FragmentExtensionsKt + .showFragment(this, ReportsOverviewFragment.newInstance(), R.id.main_fragment_container, false); + } else { + FragmentExtensionsKt.showFragment(this, ReportsFragment.newInstance(null), R.id.main_fragment_container, false); + } } private void setupNotification() { cardNotifications.setOnClickListener(v -> showReportsFragment()); - tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> { - //update status view - if (loadingView.getVisibility() == VISIBLE) { + tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> + updateNotification(tracingStatusInterface, crowdNotifierViewModel.hasTraceKeyLoadingError().getValue())); + crowdNotifierViewModel.hasTraceKeyLoadingError().observe(getViewLifecycleOwner(), hasTraceKeyLoadingError -> + updateNotification(tracingViewModel.getAppStatusLiveData().getValue(), hasTraceKeyLoadingError)); + } + + private void updateNotification(TracingStatusInterface tracingStatusInterface, boolean hasCheckinKeyLoadingError) { + //update status view + if (loadingView.getVisibility() == VISIBLE) { + loadingView.animate() + .setStartDelay(getResources().getInteger(android.R.integer.config_mediumAnimTime)) + .setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)) + .alpha(0f) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + loadingView.setVisibility(View.GONE); + } + }); + } else { + loadingView.setVisibility(View.GONE); + } + if (tracingStatusInterface.isReportedAsInfected()) { + NotificationStateHelper.updateStatusView(reportStatusView, NotificationState.POSITIVE_TESTED); + } else if (tracingStatusInterface.wasContactReportedAsExposed() || + !crowdNotifierViewModel.getExposures().getValue().isEmpty()) { + long daysSinceExposureTracing = tracingStatusInterface.getDaysSinceExposure(); + long daysSinceExposureCheckin = crowdNotifierViewModel.getDaysSinceExposure(); + long daysSinceExposure; + if (daysSinceExposureTracing >= 0 && daysSinceExposureCheckin >= 0) { + daysSinceExposure = Math.min(daysSinceExposureTracing, daysSinceExposureCheckin); + } else { + daysSinceExposure = Math.max(daysSinceExposureTracing, daysSinceExposureCheckin); + } + NotificationStateHelper.updateStatusView(reportStatusView, NotificationState.EXPOSED, daysSinceExposure); + } else { + NotificationStateHelper.updateStatusView(reportStatusView, NotificationState.NO_REPORTS); + } + + TracingStatus.ErrorState errorState = tracingStatusInterface.getReportErrorState(); + + if (errorState != null && tracingStatusInterface.getTracingState().equals(TracingState.ACTIVE)) { + TracingErrorStateHelper.updateErrorView(reportErrorView, errorState); + reportErrorView.findViewById(R.id.error_status_button).setOnClickListener(v -> { + loadingView.setVisibility(VISIBLE); loadingView.animate() - .setStartDelay(getResources().getInteger(android.R.integer.config_mediumAnimTime)) + .alpha(1f) .setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)) - .alpha(0f) .setListener(new AnimatorListenerAdapter() { @Override - public void onAnimationEnd(Animator animation) { - loadingView.setVisibility(View.GONE); - } + public void onAnimationEnd(Animator animation) { tracingViewModel.sync(); } }); - } else { - loadingView.setVisibility(View.GONE); - } - if (tracingStatusInterface.isReportedAsInfected()) { - NotificationStateHelper.updateStatusView(reportStatusView, NotificationState.POSITIVE_TESTED); - } else if (tracingStatusInterface.wasContactReportedAsExposed()) { - long daysSinceExposure = tracingStatusInterface.getDaysSinceExposure(); - NotificationStateHelper.updateStatusView(reportStatusView, NotificationState.EXPOSED, daysSinceExposure); - } else { - NotificationStateHelper.updateStatusView(reportStatusView, NotificationState.NO_REPORTS); - } - - TracingStatus.ErrorState errorState = tracingStatusInterface.getReportErrorState(); - if (tracingStatusInterface.getTracingState().equals(TracingState.NOT_ACTIVE) && - !tracingStatusInterface.isReportedAsInfected()) { - NotificationErrorStateHelper - .updateNotificationErrorView(reportErrorView, NotificationStateError.TRACING_DEACTIVATED); - reportErrorView.findViewById(R.id.error_status_button).setOnClickListener(v -> { - enableTracing(); - }); - } else if (errorState != null) { - TracingErrorStateHelper - .updateErrorView(reportErrorView, errorState); - reportErrorView.findViewById(R.id.error_status_button).setOnClickListener(v -> { - loadingView.setVisibility(VISIBLE); - loadingView.animate() - .alpha(1f) - .setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)) - .setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { tracingViewModel.sync(); } - }); - }); - } else if (!isNotificationChannelEnabled(getContext(), NotificationUtil.NOTIFICATION_CHANNEL_ID)) { - NotificationErrorStateHelper - .updateNotificationErrorView(reportErrorView, NotificationStateError.NOTIFICATION_STATE_ERROR); - reportErrorView.findViewById(R.id.error_status_button).setOnClickListener(v -> { - openChannelSettings(NotificationUtil.NOTIFICATION_CHANNEL_ID); - }); - } else { - //hide errorview - TracingErrorStateHelper.updateErrorView(reportErrorView, null); - } - }); + }); + } else if (hasCheckinKeyLoadingError) { + TracingErrorStateHelper.updateErrorView(reportErrorView, CrowdNotifierErrorState.NETWORK); + reportErrorView.findViewById(R.id.error_status_button).setOnClickListener(v -> { + loadingView.setVisibility(VISIBLE); + loadingView.animate() + .alpha(1f) + .setDuration(getResources().getInteger(android.R.integer.config_mediumAnimTime)) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { crowdNotifierViewModel.refreshTraceKeys(); } + }); + }); + } else if (!isNotificationChannelEnabled(getContext(), NotificationUtil.NOTIFICATION_CHANNEL_ID)) { + NotificationErrorStateHelper + .updateNotificationErrorView(reportErrorView, NotificationStateError.NOTIFICATION_STATE_ERROR); + reportErrorView.findViewById(R.id.error_status_button).setOnClickListener(v -> { + openChannelSettings(NotificationUtil.NOTIFICATION_CHANNEL_ID); + }); + } else { + TracingErrorStateHelper.hideErrorView(reportErrorView); + } } private void openChannelSettings(String channelId) { @@ -360,38 +376,142 @@ private boolean isNotificationChannelEnabled(Context context, @Nullable String c } } - private void setupTravelCard() { - travelCard.setOnClickListener( - v -> getActivity().getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) - .replace(R.id.main_fragment_container, TravelFragment.newInstance()) - .addToBackStack(TravelFragment.class.getCanonicalName()) - .commit() - ); - - List countries = secureStorage.getInteropCountries(); - if (!countries.isEmpty()) { - travelCard.setVisibility(VISIBLE); - Flow flowConstraint = travelCard.findViewById(R.id.travel_flags_flow); - TravelUtils.inflateFlagFlow(flowConstraint, countries); - } else { - travelCard.setVisibility(View.GONE); - } + private void setupCheckinCard() { + + tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> { + if (tracingStatusInterface.isReportedAsInfected()) { + setupCheckinCardIsolationMode(); + } else { + setupCheckinCardNonIsolationMode(); + } + }); } - private void setupWhatToDo() { - cardSymptoms.setOnClickListener( - v -> getActivity().getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) - .replace(R.id.main_fragment_container, WtdSymptomsFragment.newInstance()) - .addToBackStack(WtdSymptomsFragment.class.getCanonicalName()) - .commit()); - cardTest.setOnClickListener( - v -> getActivity().getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) - .replace(R.id.main_fragment_container, WtdPositiveTestFragment.newInstance()) - .addToBackStack(WtdPositiveTestFragment.class.getCanonicalName()) - .commit()); + private void setupCheckinCardIsolationMode() { + checkinCard.setOnClickListener(v -> showCheckinOverviewFragment()); + View checkinView = checkinCard.findViewById(R.id.checkin_view); + View checkoutView = checkinCard.findViewById(R.id.checkout_view); + View isolationView = checkinCard.findViewById(R.id.isolation_view); + + checkinView.setVisibility(View.GONE); + checkoutView.setVisibility(View.GONE); + isolationView.setVisibility(View.VISIBLE); + + TextView title = checkinCard.findViewById(R.id.status_title); + title.setTextColor(ResourcesCompat.getColor(getResources(), R.color.purple_main, null)); + title.setText(R.string.checkin_ended_title); + + TextView subtitle = checkinCard.findViewById(R.id.status_text); + subtitle.setTextColor(ResourcesCompat.getColor(getResources(), R.color.purple_main, null)); + subtitle.setText(R.string.checkin_ended_text); + + checkinCard.findViewById(R.id.status_background) + .setBackgroundTintList(ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.status_purple_bg))); + + ((ImageView) checkinCard.findViewById(R.id.status_icon)).setImageResource(R.drawable.ic_stopp); + + ((ImageView) checkinCard.findViewById(R.id.status_illustration)).setImageResource(R.drawable.ic_illu_checkin_ended); + } + + private void setupCheckinCardNonIsolationMode() { + checkinCard.setOnClickListener(v -> showCheckinOverviewFragment()); + + View checkinView = checkinCard.findViewById(R.id.checkin_view); + View checkoutView = checkinCard.findViewById(R.id.checkout_view); + View isolationView = checkinCard.findViewById(R.id.isolation_view); + TextView checkinVenueTitle = checkinCard.findViewById(R.id.checkin_venue_title); + + isolationView.setVisibility(View.GONE); + crowdNotifierViewModel.isCheckedIn().observe(getViewLifecycleOwner(), isCheckedIn -> { + if (isCheckedIn) { + checkoutView.setVisibility(View.VISIBLE); + checkinView.setVisibility(View.GONE); + checkinVenueTitle.setText(crowdNotifierViewModel.getCheckInState().getVenueInfo().getTitle()); + crowdNotifierViewModel.startCheckInTimer(); + } else { + checkoutView.setVisibility(View.GONE); + checkinView.setVisibility(View.VISIBLE); + } + }); + + checkinCard.findViewById(R.id.checkin_button).setOnClickListener(v -> showQrCodeScannerFragment()); + checkinCard.findViewById(R.id.checkout_button).setOnClickListener(v -> showCheckOutFragment()); + + TextView checkinTime = checkinCard.findViewById(R.id.checkin_time); + crowdNotifierViewModel.getTimeSinceCheckIn().observe(getViewLifecycleOwner(), + duration -> checkinTime.setText(StringUtil.getShortDurationString(duration))); + } + + private void setupCovidCodeCard() { + Button covidCodeButton = covidCodeCard.findViewById(R.id.enter_covidcode_button); + TextView covidCodeTitle = covidCodeCard.findViewById(R.id.enter_covidcode_title); + TextView covidCodeText = covidCodeCard.findViewById(R.id.enter_covidcode_text); + + tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> { + if (tracingStatusInterface.isReportedAsInfected()) { + covidCodeButton.setText(R.string.delete_infection_button); + covidCodeTitle.setText(R.string.home_end_isolation_card_title); + covidCodeText.setText(R.string.home_end_isolation_card_text); + covidCodeButton.setOnClickListener(v -> { + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle); + builder.setMessage(R.string.delete_infection_dialog) + .setPositiveButton(R.string.delete_infection_dialog_finish_button, (dialog, id) -> { + TracingStatusHelper.resetStateAfterIsolation(getActivity(), tracingViewModel); + }) + .setNegativeButton(R.string.cancel, (dialog, id) -> { + //do nothing + }); + builder.create(); + builder.show(); + }); + } else { + covidCodeButton.setText(R.string.inform_code_title); + covidCodeTitle.setText(R.string.home_covidcode_card_title); + covidCodeText.setText(R.string.home_covidcode_card_text); + covidCodeButton.setOnClickListener(v -> { + if (crowdNotifierViewModel.isCheckedIn().getValue()) { + showCannotEnterCovidcodeWhileCheckedInDialog(); + } else { + Intent intent = new Intent(getActivity(), InformActivity.class); + startActivity(intent); + } + }); + } + }); + } + + private void showCannotEnterCovidcodeWhileCheckedInDialog() { + new AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.error_cannot_enter_covidcode_while_checked_in) + .setPositiveButton(R.string.checkout_button_title, (dialog, id) -> showCheckOutFragment()) + .setNegativeButton(R.string.cancel, (dialog, id) -> dialog.dismiss()) + .create() + .show(); + } + + private void showCheckOutFragment() { + requireActivity().getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.modal_slide_enter, R.anim.modal_slide_exit, R.anim.modal_pop_enter, + R.anim.modal_pop_exit) + .replace(R.id.main_fragment_container, CheckOutFragment.newInstance()) + .addToBackStack(CheckOutFragment.class.getCanonicalName()) + .commit(); + } + + private void showQrCodeScannerFragment() { + requireActivity().getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) + .replace(R.id.main_fragment_container, QrCodeScannerFragment.newInstance()) + .addToBackStack(QrCodeScannerFragment.class.getCanonicalName()) + .commit(); + } + + private void showCheckinOverviewFragment() { + requireActivity().getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) + .replace(R.id.main_fragment_container, CheckinOverviewFragment.newInstance()) + .addToBackStack(CheckinOverviewFragment.class.getCanonicalName()) + .commit(); } private void setupNonProductionHint() { @@ -430,9 +550,7 @@ public void onChanged(TracingStatusInterface tracingStatusInterface) { .setTitle(R.string.homescreen_isolation_ended_popup_title) .setMessage(R.string.homescreen_isolation_ended_popup_text) .setPositiveButton(R.string.answer_yes, (dialog, which) -> { - tracingStatusInterface.resetInfectionStatus(getContext()); - secureStorage.setIsolationEndDialogTimestamp(-1L); - secureStorage.setPositiveReportOldestSharedKey(-1L); + TracingStatusHelper.resetStateAfterIsolation(getActivity(), tracingViewModel); }) .setNegativeButton(R.string.answer_no, (dialog, which) -> { long newTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); @@ -448,26 +566,4 @@ public void onChanged(TracingStatusInterface tracingStatusInterface) { tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), observer); } - private void enableTracing() { - Activity activity = getActivity(); - if (activity == null) { - return; - } - - tracingViewModel.enableTracing(activity, - () -> { }, - e -> { - String message = ENExceptionHelper.getErrorMessage(e, activity); - Logger.e(TAG, message); - new AlertDialog.Builder(activity, R.style.NextStep_AlertDialogStyle) - .setTitle(R.string.android_en_start_failure) - .setMessage(message) - .setPositiveButton(R.string.android_button_ok, (dialog, which) -> {}) - .show(); - }, - () -> { - // cancelled - }); - } - } diff --git a/app/src/main/java/ch/admin/bag/dp3t/home/TracingBoxFragment.java b/app/src/main/java/ch/admin/bag/dp3t/home/TracingBoxFragment.java index bb1ad7aa5..1abd4b82d 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/home/TracingBoxFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/home/TracingBoxFragment.java @@ -27,8 +27,11 @@ import org.dpppt.android.sdk.TracingStatus; import org.dpppt.android.sdk.internal.logger.Logger; +import ch.admin.bag.dp3t.MainActivity; import ch.admin.bag.dp3t.R; import ch.admin.bag.dp3t.home.model.TracingState; +import ch.admin.bag.dp3t.onboarding.OnboardingType; +import ch.admin.bag.dp3t.storage.SecureStorage; import ch.admin.bag.dp3t.util.DeviceFeatureHelper; import ch.admin.bag.dp3t.util.ENExceptionHelper; import ch.admin.bag.dp3t.util.TracingErrorStateHelper; @@ -49,7 +52,6 @@ public class TracingBoxFragment extends Fragment { private View tracingErrorView; private boolean isHomeFragment; - private View tracingLoadingView; public TracingBoxFragment() { super(R.layout.fragment_tracing_box); @@ -76,34 +78,38 @@ public void onCreate(@Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { tracingStatusView = view.findViewById(R.id.tracing_status); tracingErrorView = view.findViewById(R.id.tracing_error); - tracingLoadingView = view.findViewById(R.id.tracing_loading_view); showStatus(); } private void showStatus() { + tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> { boolean isTracing = tracingStatusInterface.getTracingState().equals(TracingState.ACTIVE); TracingStatus.ErrorState errorState = tracingStatusInterface.getTracingErrorState(); - if (isTracing && errorState != null) { + if (SecureStorage.getInstance(requireContext()).getOnlyPartialOnboardingCompleted()) { + tracingStatusView.setVisibility(View.GONE); + tracingErrorView.setVisibility(View.VISIBLE); + TracingStatusHelper.showFinishPartialOnboarding(tracingErrorView); + tracingErrorView.findViewById(R.id.error_status_button).setOnClickListener( + v -> ((MainActivity) requireActivity()).launchOnboarding(OnboardingType.NON_INSTANT_PART, null)); + } else if (isTracing && errorState != null) { handleErrorState(errorState); } else if (tracingStatusInterface.isReportedAsInfected()) { tracingStatusView.setVisibility(View.VISIBLE); tracingErrorView.setVisibility(View.GONE); - TracingStatusHelper.updateStatusView(tracingStatusView, TracingState.ENDED, isHomeFragment); + TracingStatusHelper.updateStatusView(tracingStatusView, TracingState.ENDED); } else if (!isTracing) { tracingStatusView.setVisibility(View.GONE); tracingErrorView.setVisibility(View.VISIBLE); TracingStatusHelper.showTracingDeactivated(tracingErrorView, isHomeFragment); TextView buttonView = tracingErrorView.findViewById(R.id.error_status_button); - buttonView.setOnClickListener(v -> { - enableTracing(); - }); + buttonView.setOnClickListener(v -> enableTracing()); } else { tracingStatusView.setVisibility(View.VISIBLE); tracingErrorView.setVisibility(View.GONE); - TracingStatusHelper.updateStatusView(tracingStatusView, TracingState.ACTIVE, isHomeFragment); + TracingStatusHelper.updateStatusView(tracingStatusView, TracingState.ACTIVE); } }); } diff --git a/app/src/main/java/ch/admin/bag/dp3t/home/model/NotificationState.java b/app/src/main/java/ch/admin/bag/dp3t/home/model/NotificationState.java index c34748397..6a0eef753 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/home/model/NotificationState.java +++ b/app/src/main/java/ch/admin/bag/dp3t/home/model/NotificationState.java @@ -7,11 +7,11 @@ * * SPDX-License-Identifier: MPL-2.0 */ - package ch.admin.bag.dp3t.home.model; import androidx.annotation.ColorRes; import androidx.annotation.DrawableRes; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import ch.admin.bag.dp3t.R; @@ -21,7 +21,8 @@ public enum NotificationState { EXPOSED, POSITIVE_TESTED; - @StringRes public static int getTitle(NotificationState notificationState) { + @StringRes + public static int getTitle(NotificationState notificationState) { switch (notificationState) { case NO_REPORTS: return R.string.meldungen_no_meldungen_title; @@ -33,7 +34,8 @@ public enum NotificationState { return -1; } - @StringRes public static int getText(NotificationState notificationState) { + @StringRes + public static int getText(NotificationState notificationState) { switch (notificationState) { case NO_REPORTS: return R.string.meldungen_no_meldungen_subtitle; @@ -45,22 +47,38 @@ public enum NotificationState { return -1; } - @DrawableRes public static int getIcon(NotificationState notificationState) { + @DrawableRes + public static int getIcon(NotificationState notificationState) { switch (notificationState) { case NO_REPORTS: - return R.drawable.ic_check; + return R.drawable.ic_check_circle; case EXPOSED: - return R.drawable.ic_info; + return R.drawable.ic_warning_round; case POSITIVE_TESTED: return R.drawable.ic_info; } return -1; } - @ColorRes public static int getTitleTextColor(NotificationState notificationState) { + @ColorRes + @Nullable + public static Integer getIconColor(NotificationState notificationState) { + switch (notificationState) { + case NO_REPORTS: + return null; + case EXPOSED: + return R.color.white; + case POSITIVE_TESTED: + return R.color.white; + } + return null; + } + + @ColorRes + public static int getTitleTextColor(NotificationState notificationState) { switch (notificationState) { case NO_REPORTS: - return R.color.green_main; + return R.color.blue_main; case EXPOSED: return R.color.white; case POSITIVE_TESTED: @@ -69,7 +87,8 @@ public enum NotificationState { return -1; } - @ColorRes public static int geTextColor(NotificationState notificationState) { + @ColorRes + public static int getTextColor(NotificationState notificationState) { switch (notificationState) { case NO_REPORTS: return R.color.dark_main; @@ -81,10 +100,11 @@ public enum NotificationState { return -1; } - @ColorRes public static int getBackgroundColor(NotificationState notificationState) { + @ColorRes + public static int getBackgroundColor(NotificationState notificationState) { switch (notificationState) { case NO_REPORTS: - return R.color.status_green_bg; + return R.color.status_blue_bg; case EXPOSED: return R.color.blue_main; case POSITIVE_TESTED: diff --git a/app/src/main/java/ch/admin/bag/dp3t/home/model/TracingState.java b/app/src/main/java/ch/admin/bag/dp3t/home/model/TracingState.java index 8df30ca50..c65846a3d 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/home/model/TracingState.java +++ b/app/src/main/java/ch/admin/bag/dp3t/home/model/TracingState.java @@ -34,13 +34,12 @@ int getTitle(TracingState tracingState) { } public static @StringRes - int getText(TracingState tracingState, boolean isHomeFragment) { + int getText(TracingState tracingState) { switch (tracingState) { case ACTIVE: return R.string.tracing_active_text; case NOT_ACTIVE: - if (isHomeFragment) return R.string.tracing_turned_off_text; - else return R.string.tracing_turned_off_detailed_text; + return R.string.tracing_turned_off_detailed_text; case ENDED: return R.string.tracing_ended_text; } @@ -53,7 +52,7 @@ int getIcon(TracingState tracingState) { case ACTIVE: return R.drawable.ic_check; case NOT_ACTIVE: - return R.drawable.ic_warning_red; + return R.drawable.ic_info; case ENDED: return R.drawable.ic_stopp; } diff --git a/app/src/main/java/ch/admin/bag/dp3t/home/views/HeaderView.java b/app/src/main/java/ch/admin/bag/dp3t/home/views/HeaderView.java index 346b5ff4e..ac85cec89 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/home/views/HeaderView.java +++ b/app/src/main/java/ch/admin/bag/dp3t/home/views/HeaderView.java @@ -141,16 +141,10 @@ public void setState(TracingStatusInterface state) { iconRes = R.drawable.ic_warning; } } else { - if (state.getTracingState() == TracingState.ACTIVE) { - iconRes = R.drawable.ic_begegnungen; - iconTintColor = R.color.white; - iconBgRes = R.drawable.bg_header_icon_on; - backgroundColor = getResources().getColor(R.color.header_bg_on, null); - } else { - iconRes = R.drawable.ic_warning_red; - iconBgRes = R.drawable.bg_header_icon_off; - backgroundColor = getResources().getColor(R.color.header_bg_off, null); - } + iconRes = R.drawable.ic_begegnungen; + iconTintColor = R.color.white; + iconBgRes = R.drawable.bg_header_icon_on; + backgroundColor = getResources().getColor(R.color.header_bg_on, null); } } else if (state.getNotificationState() == NotificationState.POSITIVE_TESTED) { backgroundColor = getResources().getColor(R.color.header_bg_exposed, null); diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/CheckinAdapter.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/CheckinAdapter.kt new file mode 100644 index 000000000..57b50c069 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/CheckinAdapter.kt @@ -0,0 +1,67 @@ +package ch.admin.bag.dp3t.inform + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.databinding.ItemSelectableCheckinBinding +import ch.admin.bag.dp3t.inform.models.SelectableCheckinItem +import ch.admin.bag.dp3t.util.StringUtil + +class CheckinAdapter : RecyclerView.Adapter() { + + private var checkins = mutableListOf() + private var itemSelectionListener: ((selectedItem: DiaryEntry, selected: Boolean) -> Unit)? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheckinViewHolder { + return CheckinViewHolder(ItemSelectableCheckinBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holderCheckin: CheckinViewHolder, position: Int) { + holderCheckin.bind(checkins[position]) + } + + override fun getItemCount() = checkins.size + + inner class CheckinViewHolder(private val binding: ItemSelectableCheckinBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(selectableCheckinItem: SelectableCheckinItem) { + binding.apply { + checkinTitle.text = selectableCheckinItem.diaryEntry.venueInfo.title + checkinDetail2.text = + StringUtil.getReportDateString(selectableCheckinItem.diaryEntry.checkOutTime, true, true, root.context) + checkbox.isChecked = selectableCheckinItem.isSelected + setStroke(selectableCheckinItem.isSelected) + + checkbox.setOnCheckedChangeListener { _, isChecked -> + selectableCheckinItem.isSelected = isChecked + setStroke(isChecked) + itemSelectionListener?.invoke(selectableCheckinItem.diaryEntry, isChecked) + } + root.setOnClickListener { checkbox.isChecked = !checkbox.isChecked } + } + } + + private fun setStroke(isSelected: Boolean) { + binding.apply { + if (isSelected) { + root.strokeWidth = root.context.resources.getDimensionPixelSize(R.dimen.stroke_width_default) + } else { + root.strokeWidth = 0 + } + root.requestLayout() + } + } + } + + fun setData(selectableCheckinItems: List) { + checkins.clear() + checkins.addAll(selectableCheckinItems) + notifyDataSetChanged() + } + + fun itemSelectionListener(listener: (selectedItem: DiaryEntry, selected: Boolean) -> Unit) { + itemSelectionListener = listener + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/InformActivity.java b/app/src/main/java/ch/admin/bag/dp3t/inform/InformActivity.java index 241454e21..e0d87bfce 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/InformActivity.java +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/InformActivity.java @@ -20,7 +20,7 @@ public class InformActivity extends FragmentActivity { - private boolean allowed = true; + private boolean backpressAllowed = true; public static final String EXTRA_COVIDCODE = "EXTRA_COVIDCODE"; @Override @@ -38,13 +38,13 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { @Override public void onBackPressed() { - if (allowed) { + if (backpressAllowed) { super.onBackPressed(); } } public void allowBackButton(boolean allowed) { - this.allowed = allowed; + this.backpressAllowed = allowed; } @Override diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.java b/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.java deleted file mode 100644 index 2762d1c83..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.java +++ /dev/null @@ -1,301 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.inform; - -import android.os.Bundle; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.ViewModelProvider; - -import java.util.Date; -import java.util.concurrent.CancellationException; -import java.util.concurrent.TimeUnit; - -import com.google.android.gms.common.api.ApiException; - -import org.dpppt.android.sdk.DP3T; -import org.dpppt.android.sdk.backend.ResponseCallback; -import org.dpppt.android.sdk.internal.logger.Logger; -import org.dpppt.android.sdk.models.DayDate; -import org.dpppt.android.sdk.models.ExposeeAuthMethodAuthorization; - -import ch.admin.bag.dp3t.R; -import ch.admin.bag.dp3t.inform.views.ChainedEditText; -import ch.admin.bag.dp3t.networking.AuthCodeRepository; -import ch.admin.bag.dp3t.networking.errors.InvalidCodeError; -import ch.admin.bag.dp3t.networking.errors.ResponseError; -import ch.admin.bag.dp3t.networking.models.AuthenticationCodeRequestModel; -import ch.admin.bag.dp3t.networking.models.AuthenticationCodeResponseModel; -import ch.admin.bag.dp3t.storage.SecureStorage; -import ch.admin.bag.dp3t.util.ENExceptionHelper; -import ch.admin.bag.dp3t.util.JwtUtil; -import ch.admin.bag.dp3t.util.PhoneUtil; -import ch.admin.bag.dp3t.viewmodel.TracingViewModel; - -import static ch.admin.bag.dp3t.inform.InformActivity.EXTRA_COVIDCODE; - -public class InformFragment extends Fragment { - - private static final String TAG = "InformFragment"; - - private static final long TIMEOUT_VALID_CODE = 1000L * 60 * 5; - private final long MAX_EXPOSURE_AGE_MILLIS = 10 * 24 * 60 * 60 * 1000L; - - private static final String REGEX_CODE_PATTERN = "\\d{" + ChainedEditText.NUM_CHARACTERS + "}"; - - private ChainedEditText authCodeInput; - private AlertDialog progressDialog; - private Button buttonSend; - - private SecureStorage secureStorage; - private TracingViewModel tracingViewModel; - - public static InformFragment newInstance() { - return new InformFragment(); - } - - public InformFragment() { - super(R.layout.fragment_inform); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - secureStorage = SecureStorage.getInstance(getContext()); - tracingViewModel = new ViewModelProvider(requireActivity()).get(TracingViewModel.class); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ((InformActivity) requireActivity()).allowBackButton(true); - buttonSend = view.findViewById(R.id.trigger_fragment_button_trigger); - authCodeInput = view.findViewById(R.id.trigger_fragment_input); - authCodeInput.addTextChangedListener(new ChainedEditText.ChainedEditTextListener() { - @Override - public void onTextChanged(String input) { - buttonSend.setEnabled(input.matches(REGEX_CODE_PATTERN)); - } - - @Override - public void onEditorSendAction() { - if (buttonSend.isEnabled()) buttonSend.callOnClick(); - } - }); - - long lastRequestTime = secureStorage.getLastInformRequestTime(); - String lastCode = secureStorage.getLastInformCode(); - String lastToken = secureStorage.getLastInformToken(); - - if (System.currentTimeMillis() - lastRequestTime < TIMEOUT_VALID_CODE) { - authCodeInput.setText(lastCode); - } else if (lastCode != null || lastToken != null) { - secureStorage.clearInformTimeAndCodeAndToken(); - } - - if (requireActivity().getIntent().getExtras() != null) { - String covidCode = requireActivity().getIntent().getExtras().getString(EXTRA_COVIDCODE); - if (covidCode != null) { - authCodeInput.setText(covidCode); - } - } - - buttonSend.setOnClickListener(v -> { - buttonSend.setEnabled(false); - setInvalidCodeErrorVisible(false); - String authCode = authCodeInput.getText(); - - boolean isTracingEnabled = DP3T.isTracingEnabled(requireContext()); - if (isTracingEnabled) { - authenticateInputOrInformExposed(authCode); - } else { - askUserToEnableTracing(authCode); - } - }); - - view.findViewById(R.id.cancel_button).setOnClickListener(v -> getActivity().finish()); - - view.findViewById(R.id.inform_invalid_code_error).setOnClickListener(v -> PhoneUtil.callAppHotline(v.getContext())); - } - - @Override - public void onResume() { - super.onResume(); - authCodeInput.requestFocus(); - } - - private void authenticateInputOrInformExposed(String authCode) { - long lastTimestamp = secureStorage.getLastInformRequestTime(); - String lastAuthToken = secureStorage.getLastInformToken(); - - progressDialog = createProgressDialog(); - if (System.currentTimeMillis() - lastTimestamp < TIMEOUT_VALID_CODE && lastAuthToken != null) { - Date onsetDate = JwtUtil.getOnsetDate(lastAuthToken); - informExposed(onsetDate, getAuthorizationHeader(lastAuthToken)); - } else { - authenticateInput(authCode); - } - } - - private void askUserToEnableTracing(String authCode) { - new AlertDialog.Builder(getContext(), R.style.NextStep_AlertDialogStyle) - .setMessage(R.string.android_inform_tracing_enabled_explanation) - .setOnCancelListener(dialog -> buttonSend.setEnabled(true)) - .setNegativeButton(R.string.cancel, (dialog, which) -> buttonSend.setEnabled(true)) - .setPositiveButton(R.string.activate_tracing_button, (dialog, which) -> enableTracing(authCode)) - .show(); - } - - private void enableTracing(String authCode) { - tracingViewModel.enableTracing(getActivity(), - () -> authenticateInputOrInformExposed(authCode), - (e) -> { - String message = ENExceptionHelper.getErrorMessage(e, getActivity()); - Logger.e(TAG, message); - new AlertDialog.Builder(getActivity(), R.style.NextStep_AlertDialogStyle) - .setTitle(R.string.android_en_start_failure) - .setMessage(message) - .setOnDismissListener(dialog -> buttonSend.setEnabled(true)) - .setPositiveButton(R.string.android_button_ok, (dialog, which) -> {}) - .show(); - }, - () -> buttonSend.setEnabled(true)); - } - - private void authenticateInput(String authCode) { - AuthCodeRepository authCodeRepository = new AuthCodeRepository(getContext()); - authCodeRepository.getAccessToken(new AuthenticationCodeRequestModel(authCode, 0), - new ResponseCallback() { - @Override - public void onSuccess(AuthenticationCodeResponseModel response) { - String accessToken = response.getAccessToken(); - - secureStorage.saveInformTimeAndCodeAndToken(authCode, accessToken); - - Date onsetDate = JwtUtil.getOnsetDate(accessToken); - if (onsetDate == null) { - showErrorDialog(InformRequestError.BLACK_INVALID_AUTH_RESPONSE_FORM); - if (progressDialog != null && progressDialog.isShowing()) { - progressDialog.dismiss(); - } - buttonSend.setEnabled(true); - return; - } - informExposed(onsetDate, getAuthorizationHeader(accessToken)); - } - - @Override - public void onError(Throwable throwable) { - if (progressDialog != null && progressDialog.isShowing()) { - progressDialog.dismiss(); - } - if (throwable instanceof InvalidCodeError) { - setInvalidCodeErrorVisible(true); - return; - } else if (throwable instanceof ResponseError) { - showErrorDialog(InformRequestError.BLACK_STATUS_ERROR, - String.valueOf(((ResponseError) throwable).getStatusCode())); - } else { - showErrorDialog(InformRequestError.BLACK_MISC_NETWORK_ERROR); - } - buttonSend.setEnabled(true); - } - }, - getViewLifecycleOwner()); - } - - private void informExposed(Date onsetDate, String authorizationHeader) { - DP3T.sendIAmInfected(getActivity(), onsetDate, - new ExposeeAuthMethodAuthorization(authorizationHeader), new ResponseCallback() { - @Override - public void onSuccess(DayDate oldestSharedKeyDayDate) { - if (progressDialog != null && progressDialog.isShowing()) { - progressDialog.dismiss(); - } - secureStorage.clearInformTimeAndCodeAndToken(); - - // Store the oldest shared Key date of this report (but at least now-MAX_EXPOSURE_AGE_MILLIS) - long oldestSharedKeyDate = Math.max(oldestSharedKeyDayDate.getStartOfDayTimestamp(), - System.currentTimeMillis() - MAX_EXPOSURE_AGE_MILLIS); - secureStorage.setPositiveReportOldestSharedKey(oldestSharedKeyDate); - - // Ask if user wants to end isolation after 14 days - long isolationEndDialogTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(14); - secureStorage.setIsolationEndDialogTimestamp(isolationEndDialogTimestamp); - - getParentFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, - R.anim.slide_pop_exit) - .replace(R.id.inform_fragment_container, ThankYouFragment.newInstance()) - .commit(); - } - - @Override - public void onError(Throwable throwable) { - if (progressDialog != null && progressDialog.isShowing()) { - progressDialog.dismiss(); - } - if (throwable instanceof ResponseError) { - showErrorDialog(InformRequestError.RED_STATUS_ERROR, - String.valueOf(((ResponseError) throwable).getStatusCode())); - } else if (throwable instanceof CancellationException) { - showErrorDialog(InformRequestError.RED_USER_CANCELLED_SHARE); - } else if (throwable instanceof ApiException) { - showErrorDialog(InformRequestError.RED_EXPOSURE_API_ERROR, - String.valueOf(((ApiException) throwable).getStatusCode())); - } else { - showErrorDialog(InformRequestError.RED_MISC_NETWORK_ERROR); - } - throwable.printStackTrace(); - buttonSend.setEnabled(true); - } - }); - } - - private void setInvalidCodeErrorVisible(boolean visible) { - getView().findViewById(R.id.inform_invalid_code_error).setVisibility(visible ? View.VISIBLE : View.GONE); - getView().findViewById(R.id.inform_input_text).setVisibility(visible ? View.GONE : View.VISIBLE); - } - - private AlertDialog createProgressDialog() { - return new AlertDialog.Builder(getContext()) - .setView(R.layout.dialog_loading) - .show(); - } - - private void showErrorDialog(InformRequestError error) { - showErrorDialog(error, null); - } - - private void showErrorDialog(InformRequestError error, @Nullable String addErrorCode) { - AlertDialog.Builder errorDialogBuilder = new AlertDialog.Builder(getContext(), R.style.NextStep_AlertDialogStyle) - .setMessage(error.getErrorMessage()) - .setPositiveButton(R.string.android_button_ok, (dialog, which) -> {}); - String errorCode = error.getErrorCode(addErrorCode); - TextView errorCodeView = - (TextView) getLayoutInflater().inflate(R.layout.view_dialog_error_code, (ViewGroup) getView(), false); - errorCodeView.setText(errorCode); - errorDialogBuilder.setView(errorCodeView); - errorDialogBuilder.show(); - } - - private String getAuthorizationHeader(String accessToken) { - return "Bearer " + accessToken; - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.kt new file mode 100644 index 000000000..cca0b3039 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.inform + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentInformBinding +import ch.admin.bag.dp3t.extensions.showFragment +import ch.admin.bag.dp3t.inform.models.Status +import ch.admin.bag.dp3t.inform.views.ChainedEditText +import ch.admin.bag.dp3t.inform.views.ChainedEditText.ChainedEditTextListener +import ch.admin.bag.dp3t.networking.errors.InvalidCodeError +import ch.admin.bag.dp3t.networking.errors.ResponseError +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.util.PhoneUtil +import org.dpppt.android.sdk.DP3T + +private const val REGEX_CODE_PATTERN = "\\d{" + ChainedEditText.NUM_CHARACTERS + "}" + +class InformFragment : TraceKeyShareBaseFragment() { + + companion object { + private const val TAG = "InformFragment" + private const val ARG_BACK_ALLOWED = "ARG_BACK_ALLOWED" + + @JvmStatic + fun newInstance(backAllowed: Boolean): InformFragment { + val informFragment = InformFragment() + informFragment.arguments = Bundle().apply { putBoolean(ARG_BACK_ALLOWED, backAllowed) } + return informFragment + } + } + + private lateinit var binding: FragmentInformBinding + + override fun onResume() { + super.onResume() + binding.covidcodeInput.requestFocus() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentInformBinding.inflate(inflater) + return binding.apply { + (requireActivity() as InformActivity).allowBackButton(arguments?.getBoolean(ARG_BACK_ALLOWED) ?: true) + covidcodeInput.addTextChangedListener(object : ChainedEditTextListener { + override fun onTextChanged(input: String) { + val matchesRegex = input.matches(REGEX_CODE_PATTERN.toRegex()) + sendButton.isEnabled = matchesRegex + setInvalidCovidcodeErrorVisible(false) + } + + override fun onEditorSendAction() { + if (sendButton.isEnabled) sendButton.callOnClick() + } + }) + + covidcodeInput.text = informViewModel.covidCode + + if (requireActivity().intent.extras != null) { + val covidCode = requireActivity().intent.extras?.getString(InformActivity.EXTRA_COVIDCODE) + if (covidCode != null) { + covidcodeInput.text = covidCode + } + } + + sendButton.setOnClickListener { onContinueClicked() } + cancelButton.setOnClickListener { requireActivity().finish() } + informInvalidCodeError.setOnClickListener { PhoneUtil.callAppHotline(it.context) } + }.root + } + + private fun onContinueClicked() { + binding.sendButton.isEnabled = false + setInvalidCovidcodeErrorVisible(false) + informViewModel.covidCode = binding.covidcodeInput.text + loadOnsetDate() + } + + private fun loadOnsetDate() { + informViewModel.loadOnsetDate().observe(viewLifecycleOwner) { + setLoadingViewVisible(it.status == Status.LOADING) + if (it.status == Status.SUCCESS) { + val secureStorage = SecureStorage.getInstance(context) + secureStorage.exposureNotifcationsActiveBeforeEnteringCovidcode = DP3T.isTracingEnabled(requireContext()) + askUserToEnableTracingIfNecessary { tracingEnabled -> + if (tracingEnabled) { + showShareTEKsPopup(onSuccess = ::onUserGrantedTEKSharing, onError = ::onUserDidNotGrantTEKSharing) + } else { + showFragment(ReallyNotShareFragment.newInstance(), R.id.inform_fragment_container) + } + } + } else if (it.status == Status.ERROR) { + if (it.data == null) return@observe + when (it.exception) { + is ResponseError -> showErrorDialog(it.data, it.exception.statusCode.toString()) + is InvalidCodeError -> setInvalidCovidcodeErrorVisible(true) + else -> showErrorDialog(it.data) + } + } + } + } + + private fun onUserGrantedTEKSharing() { + informViewModel.hasSharedDP3TKeys = true + if (informViewModel.getSelectableCheckinItems().isEmpty()) { + performUpload() + } else { + showFragment(ShareCheckinsFragment.newInstance(), R.id.inform_fragment_container) + } + } + + private fun performUpload() { + performUpload(onSuccess = { showFragment(ThankYouFragment.newInstance(), R.id.inform_fragment_container) }) + } + + private fun onUserDidNotGrantTEKSharing() { + showFragment(ReallyNotShareFragment.newInstance(), R.id.inform_fragment_container) + } + + override fun setLoadingViewVisible(isVisible: Boolean) { + binding.loadingView.isVisible = isVisible + binding.sendButton.isEnabled = !isVisible + } + + private fun setInvalidCovidcodeErrorVisible(isVisible: Boolean) { + binding.apply { + informInvalidCodeError.visibility = if (isVisible) View.VISIBLE else View.INVISIBLE + informInputText.visibility = if (!isVisible) View.VISIBLE else View.INVISIBLE + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/InformIntroFragment.java b/app/src/main/java/ch/admin/bag/dp3t/inform/InformIntroFragment.java index 58ad7293b..560a4dc46 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/InformIntroFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/InformIntroFragment.java @@ -58,7 +58,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat getParentFragmentManager() .beginTransaction() .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) - .replace(R.id.inform_fragment_container, InformFragment.newInstance()) + .replace(R.id.inform_fragment_container, InformFragment.newInstance(true)) .addToBackStack(InformFragment.class.getCanonicalName()) .commit(); }); diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/InformRequestError.java b/app/src/main/java/ch/admin/bag/dp3t/inform/InformRequestError.java index eaf41f6aa..2e79b2e21 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/InformRequestError.java +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/InformRequestError.java @@ -7,7 +7,6 @@ * * SPDX-License-Identifier: MPL-2.0 */ - package ch.admin.bag.dp3t.inform; import ch.admin.bag.dp3t.R; @@ -19,7 +18,9 @@ public enum InformRequestError { RED_STATUS_ERROR(R.string.unexpected_error_with_retry, "ARST"), RED_USER_CANCELLED_SHARE(R.string.user_cancelled_key_sharing_error, "ARUSCCD"), RED_EXPOSURE_API_ERROR(R.string.unexpected_error_title, "AREA"), - RED_MISC_NETWORK_ERROR(R.string.network_error, "ARNETWE"); + RED_MISC_NETWORK_ERROR(R.string.network_error, "ARNETWE"), + USER_UPLOAD_NETWORK_ERROR(R.string.network_error, "AUANETWE"), + USER_UPLOAD_UNKONWN_ERROR(R.string.network_error, "AUAUKWE"); private final int errorMessage; diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/InformViewModel.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/InformViewModel.kt new file mode 100644 index 000000000..ae2fa30a0 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/InformViewModel.kt @@ -0,0 +1,281 @@ +package ch.admin.bag.dp3t.inform + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.liveData +import ch.admin.bag.dp3t.checkin.models.UploadVenueInfo +import ch.admin.bag.dp3t.checkin.models.VenueType +import ch.admin.bag.dp3t.checkin.networking.UserUploadRepository +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.extensions.getSwissCovidLocationData +import ch.admin.bag.dp3t.inform.models.Resource +import ch.admin.bag.dp3t.inform.models.SelectableCheckinItem +import ch.admin.bag.dp3t.networking.AuthCodeRepository +import ch.admin.bag.dp3t.networking.errors.InvalidCodeError +import ch.admin.bag.dp3t.networking.errors.ResponseError +import ch.admin.bag.dp3t.networking.models.AuthenticationCodeRequestModel +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.util.JwtUtil +import ch.admin.bag.dp3t.util.toUploadVenueInfo +import com.google.android.gms.common.api.ApiException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import org.crowdnotifier.android.sdk.CrowdNotifier +import org.dpppt.android.sdk.DP3T +import org.dpppt.android.sdk.DP3TKotlin +import org.dpppt.android.sdk.PendingUploadTask +import org.dpppt.android.sdk.backend.ResponseCallback +import org.dpppt.android.sdk.internal.AppConfigManager +import org.dpppt.android.sdk.models.DayDate +import org.dpppt.android.sdk.models.ExposeeAuthMethodAuthorization +import retrofit2.HttpException +import java.security.SecureRandom +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.CancellationException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.math.max +import kotlin.math.roundToLong + +private const val TIMEOUT_VALID_CODE = 1000L * 60 * 5 +private const val MAX_EXPOSURE_AGE_MILLIS = 10 * 24 * 60 * 60 * 1000L +private const val ISOLATION_DURATION_DAYS = 14L +private const val UPLOAD_REQUEST_TIME_PADDING = 5000L +private const val CHECKOUT_TIME_PADDING_MS = 30 * 60 * 1000L +private const val KEY_COVIDCODE = "KEY_COVIDCODE" +private const val KEY_HAS_SHARED_DP3T_KEYS = "KEY_HAS_SHARED_DP3T_KEYS" +private const val KEY_HAS_SHARED_CHECKINS = "KEY_HAS_SHARED_CHECKINS" +private const val KEY_PENDING_UPLOAD_TASK = "KEY_PENDING_UPLOAD_TASK" +private const val KEY_SELECTED_DIARY_ENTRIES = "KEY_SELECTED_DIARY_ENTRIES" +private const val KEY_ONSET_DATE = "KEY_ONSET_DATE" +private const val KEY_ONSET_REQUEST_TIME = "KEY_ONSET_REQUEST_TIME" + +class InformViewModel(application: Application, private val state: SavedStateHandle) : AndroidViewModel(application) { + + private val authCodeRepository = AuthCodeRepository(application) + private val userUploadRepository = UserUploadRepository() + private val diaryStorage = DiaryStorage.getInstance(application) + private val secureStorage = SecureStorage.getInstance(application) + + private var selectedDiaryEntryIds: LongArray + get() = state.get(KEY_SELECTED_DIARY_ENTRIES) ?: longArrayOf() + set(value) = state.set(KEY_SELECTED_DIARY_ENTRIES, value) + + var covidCode: String + get() = state.get(KEY_COVIDCODE) ?: getLastCovidcode() + set(value) = state.set(KEY_COVIDCODE, value) + + var hasSharedDP3TKeys: Boolean + get() = state.get(KEY_HAS_SHARED_DP3T_KEYS) ?: false + set(value) = state.set(KEY_HAS_SHARED_DP3T_KEYS, value) + + var hasSharedCheckins: Boolean + get() = state.get(KEY_HAS_SHARED_CHECKINS) ?: false + set(value) = state.set(KEY_HAS_SHARED_CHECKINS, value) + + var pendingUploadTask: PendingUploadTask? + get() = state.get(KEY_PENDING_UPLOAD_TASK) + set(value) = state.set(KEY_PENDING_UPLOAD_TASK, value) + + private var onsetDate: Long? + get() = state.get(KEY_ONSET_DATE) + set(value) = state.set(KEY_ONSET_DATE, value) + + private var onsetResponseTime: Long? + get() = state.get(KEY_ONSET_REQUEST_TIME) + set(value) = state.set(KEY_ONSET_REQUEST_TIME, value) + + fun loadOnsetDate() = liveData(Dispatchers.IO) { + emit(Resource.loading(data = null)) + try { + loadOnsetDate(covidCode) + emit(Resource.success(data = null)) + onsetResponseTime = System.currentTimeMillis() + } catch (exception: Throwable) { + when (exception) { + is InvalidCodeError -> emit(Resource.error(InformRequestError.BLACK_INVALID_AUTH_RESPONSE_FORM, exception)) + is ResponseError -> emit(Resource.error(InformRequestError.BLACK_STATUS_ERROR, exception)) + else -> emit(Resource.error(InformRequestError.BLACK_MISC_NETWORK_ERROR, exception)) + } + } + } + + fun performUpload() = liveData(Dispatchers.IO) { + emit(Resource.loading(data = null)) + val onsetResponseTime = onsetResponseTime ?: System.currentTimeMillis() + val timeBetweenOnsetAndUploadRequest = (System.currentTimeMillis() - onsetResponseTime).toInt() + delay((SecureRandom().nextDouble() * UPLOAD_REQUEST_TIME_PADDING).roundToLong()) + var oldestSharedKey: Long? = null + var oldestSharedCheckin: Long? = null + try { + loadAccessTokens(covidCode) + } catch (exception: Throwable) { + when (exception) { + is InvalidCodeError -> emit(Resource.error(InformRequestError.BLACK_INVALID_AUTH_RESPONSE_FORM, exception)) + is ResponseError -> emit(Resource.error(InformRequestError.BLACK_STATUS_ERROR, exception)) + else -> emit(Resource.error(InformRequestError.BLACK_MISC_NETWORK_ERROR, exception)) + } + return@liveData + } + try { + if (hasSharedDP3TKeys) { + oldestSharedKey = uploadTEKs() + } else { + performFakeTEKUpload() + } + } catch (exception: Throwable) { + when (exception) { + is ResponseError -> emit(Resource.error(InformRequestError.RED_STATUS_ERROR, exception)) + is CancellationException -> emit(Resource.error(InformRequestError.RED_USER_CANCELLED_SHARE, exception)) + is ApiException -> emit(Resource.error(InformRequestError.RED_EXPOSURE_API_ERROR, exception)) + else -> emit(Resource.error(InformRequestError.RED_MISC_NETWORK_ERROR, exception)) + } + return@liveData + } + + try { + if (hasSharedCheckins) { + oldestSharedCheckin = performCheckinsUpload(timeBetweenOnsetAndUploadRequest) + } else { + performFakeCheckinsUpload(timeBetweenOnsetAndUploadRequest) + } + } catch (exception: Throwable) { + when (exception) { + is HttpException -> emit(Resource.error(InformRequestError.USER_UPLOAD_NETWORK_ERROR, exception)) + else -> emit(Resource.error(InformRequestError.USER_UPLOAD_UNKONWN_ERROR, exception)) + } + return@liveData + } + + val appConfigManager = AppConfigManager.getInstance(getApplication()) + appConfigManager.iAmInfected = true + appConfigManager.iAmInfectedIsResettable = true + DP3T.stop(getApplication()) + secureStorage.positiveReportOldestSharedKeyOrCheckin = listOfNotNull(oldestSharedKey, oldestSharedCheckin).minOrNull() ?: -1 + secureStorage.clearInformTimeAndCodeAndToken() + emit(Resource.success(data = null)) + } + + fun getSelectableCheckinItems(): List { + return diaryStorage.entries.filter { + it.venueInfo.getSwissCovidLocationData().type == VenueType.USER_QR_CODE && it.checkOutTime >= onsetDate ?: 0 + && it.checkOutTime > System.currentTimeMillis() - MAX_EXPOSURE_AGE_MILLIS + }.map { + SelectableCheckinItem(it, isSelected = selectedDiaryEntryIds.contains(it.id)) + }.sortedByDescending { + it.diaryEntry.checkInTime + } + } + + fun setDiaryItemSelected(itemId: Long, isSelected: Boolean) { + selectedDiaryEntryIds = selectedDiaryEntryIds.toMutableList().apply { + if (isSelected) add(itemId) else remove(itemId) + }.distinct().toLongArray() + } + + private fun getLastCovidcode(): String { + val lastCovidcode = secureStorage.lastInformCode + + return if (System.currentTimeMillis() - secureStorage.lastInformRequestTime < TIMEOUT_VALID_CODE) { + lastCovidcode ?: "" + } else { + "" + } + } + + private suspend fun performFakeTEKUpload() { + val authorizationHeader = ExposeeAuthMethodAuthorization(getAuthorizationHeader(secureStorage.lastDP3TInformToken)) + DP3TKotlin.sendFakeInfectedRequest(getApplication(), authorizationHeader) + } + + /** + * Returns the oldestSharedKeyDate + */ + private suspend fun uploadTEKs(): Long { + val authorizationHeader = getAuthorizationHeader(secureStorage.lastDP3TInformToken) + val onsetDate = JwtUtil.getOnsetDate(secureStorage.lastDP3TInformToken) + + // Wrapping traditional callback in a suspendCoroutine + return suspendCoroutine { continuation -> + pendingUploadTask?.performUpload(getApplication(), onsetDate, ExposeeAuthMethodAuthorization(authorizationHeader), + object : ResponseCallback { + override fun onSuccess(oldestSharedKeyDayDate: DayDate) { + + // Store the oldest shared Key date of this report (but at least now-MAX_EXPOSURE_AGE_MILLIS) + val oldestSharedKeyDate = max( + oldestSharedKeyDayDate.startOfDayTimestamp, + System.currentTimeMillis() - MAX_EXPOSURE_AGE_MILLIS + ) + secureStorage.positiveReportOldestSharedKey = oldestSharedKeyDate + + // Ask if user wants to end isolation after 14 days + val isolationEndDialogTimestamp = + System.currentTimeMillis() + TimeUnit.DAYS.toMillis(ISOLATION_DURATION_DAYS) + secureStorage.isolationEndDialogTimestamp = isolationEndDialogTimestamp + hasSharedDP3TKeys = true + continuation.resume(oldestSharedKeyDate) + } + + override fun onError(throwable: Throwable) { + continuation.resumeWithException(throwable) + } + }) + } + } + + /** + * Returns oldest shared Checkin + */ + private suspend fun performCheckinsUpload(timeBetweenOnsetAndUploadRequest: Int): Long? { + val authorizationHeader = getAuthorizationHeader(secureStorage.lastCheckinInformToken) + val uploadVenueInfos = getUploadVenueInfos() + userUploadRepository.userUpload(uploadVenueInfos, timeBetweenOnsetAndUploadRequest, authorizationHeader) + return uploadVenueInfos.minOfOrNull { it.intervalStartMs } + } + + private suspend fun performFakeCheckinsUpload(timeBetweenOnsetAndUploadRequest: Int) { + val authorizationHeader = getAuthorizationHeader(secureStorage.lastCheckinInformToken) + userUploadRepository.fakeUserUpload(timeBetweenOnsetAndUploadRequest, authorizationHeader) + } + + private suspend fun loadOnsetDate(covidcode: String) { + val onsetResponse = authCodeRepository.getOnsetDate(AuthenticationCodeRequestModel(covidcode, 0)) + onsetDate = SimpleDateFormat("yyyy-MM-dd").parse(onsetResponse.onset)?.time + } + + private suspend fun loadAccessTokens(covidcode: String) { + val lastTimestamp = secureStorage.lastInformRequestTime + val accessToken = secureStorage.lastDP3TInformToken + val lastCovidcode = secureStorage.lastInformCode + if (!(System.currentTimeMillis() - lastTimestamp < TIMEOUT_VALID_CODE && accessToken != null) || covidcode != lastCovidcode) { + val accessTokens = authCodeRepository.getAccessToken(AuthenticationCodeRequestModel(covidcode, 0)) + secureStorage.saveInformTimeAndCodeAndToken( + covidcode, accessTokens.dp3TAccessToken.accessToken, accessTokens.checkInAccessToken.accessToken + ) + } + } + + private fun getUploadVenueInfos(): List { + return getSelectableCheckinItems() + .filter { + it.isSelected + } + .reversed() //checkins need to be sorted in ascending time order for upload + .map { + CrowdNotifier.generateUserUploadInfo( + it.diaryEntry.venueInfo, it.diaryEntry.checkInTime, it.diaryEntry.checkOutTime + CHECKOUT_TIME_PADDING_MS + ) + }.flatten().map { + it.toUploadVenueInfo() + } + } + + private fun getAuthorizationHeader(accessToken: String): String { + return "Bearer $accessToken" + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/NotThankYouFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/NotThankYouFragment.kt new file mode 100644 index 000000000..9a72049de --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/NotThankYouFragment.kt @@ -0,0 +1,40 @@ +package ch.admin.bag.dp3t.inform + +import android.graphics.Paint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentNotThankYouBinding +import ch.admin.bag.dp3t.extensions.showFragment + +class NotThankYouFragment : TraceKeyShareBaseFragment() { + + companion object { + fun newInstance() = NotThankYouFragment() + } + + private lateinit var binding: FragmentNotThankYouBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentNotThankYouBinding.inflate(inflater) + return binding.apply { + (requireActivity() as InformActivity).allowBackButton(false) + dontSendButton.setOnClickListener { + performUpload(onSuccess = { showFragment(TracingStoppedFragment.newInstance(), R.id.inform_fragment_container) }) + } + dontSendButton.paintFlags = dontSendButton.paintFlags or Paint.UNDERLINE_TEXT_FLAG + backButton.setOnClickListener { showFragment(ReallyNotShareFragment.newInstance(), R.id.inform_fragment_container) } + }.root + } + + override fun setLoadingViewVisible(isVisible: Boolean) { + binding.apply { + loadingView.isVisible = isVisible + dontSendButton.isEnabled = !isVisible + backButton.isEnabled = !isVisible + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/ReallyNotShareFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/ReallyNotShareFragment.kt new file mode 100644 index 000000000..1f6e4d8f3 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/ReallyNotShareFragment.kt @@ -0,0 +1,65 @@ +package ch.admin.bag.dp3t.inform + +import android.graphics.Paint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.databinding.FragmentInformReallyNotShareBinding +import ch.admin.bag.dp3t.extensions.showFragment + +class ReallyNotShareFragment : TraceKeyShareBaseFragment() { + + companion object { + fun newInstance() = ReallyNotShareFragment() + } + + private lateinit var binding: FragmentInformReallyNotShareBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentInformReallyNotShareBinding.inflate(inflater).apply { + (requireActivity() as InformActivity).allowBackButton(false) + tryAgainButton.setOnClickListener { + tryAgainButton.isEnabled = false + askUserToEnableTracingIfNecessary { isEnabled -> + if (isEnabled) { + showShareTEKsPopup(onSuccess = ::onUserGrantedTEKSharing, onError = ::onUserDidNotGrantTEKSharing) + } else { + onUserDidNotGrantTEKSharing() + } + } + } + dontSendButton.paintFlags = dontSendButton.paintFlags or Paint.UNDERLINE_TEXT_FLAG + dontSendButton.setOnClickListener { + if (DiaryStorage.getInstance(requireContext()).entries.isNotEmpty()) { + showFragment(ShareCheckinsFragment.newInstance(), R.id.inform_fragment_container) + } else { + showFragment(NotThankYouFragment.newInstance(), R.id.inform_fragment_container) + } + } + } + return binding.root + } + + private fun onUserGrantedTEKSharing() { + informViewModel.hasSharedDP3TKeys = true + if (informViewModel.getSelectableCheckinItems().isEmpty()) { + performUpload(onSuccess = { showFragment(ThankYouFragment.newInstance(), R.id.inform_fragment_container) }) + } else { + showFragment(ShareCheckinsFragment.newInstance(), R.id.inform_fragment_container) + } + } + + private fun onUserDidNotGrantTEKSharing() { + binding.tryAgainButton.isEnabled = true + } + + override fun setLoadingViewVisible(isVisible: Boolean) { + binding.loadingView.isVisible = isVisible + binding.tryAgainButton.isEnabled = !isVisible + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/ShareCheckinsFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/ShareCheckinsFragment.kt new file mode 100644 index 000000000..2795e4e1e --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/ShareCheckinsFragment.kt @@ -0,0 +1,79 @@ +package ch.admin.bag.dp3t.inform + +import android.graphics.Paint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentShareCheckinsBinding +import ch.admin.bag.dp3t.extensions.showFragment + +class ShareCheckinsFragment : TraceKeyShareBaseFragment() { + + companion object { + @JvmStatic + fun newInstance() = ShareCheckinsFragment() + } + + private lateinit var binding: FragmentShareCheckinsBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentShareCheckinsBinding.inflate(inflater) + return binding.apply { + (requireActivity() as InformActivity).allowBackButton(false) + val adapter = CheckinAdapter() + checkinsRecyclerView.adapter = adapter + adapter.setData(informViewModel.getSelectableCheckinItems()) + adapter.itemSelectionListener { selectedItem, selected -> + informViewModel.setDiaryItemSelected(selectedItem.id, selected) + refreshSendButtonAndAllCheckbox() + } + selectAllCheckbox.setOnCheckedChangeListener { _, isChecked -> + informViewModel.getSelectableCheckinItems().forEach { + informViewModel.setDiaryItemSelected(it.diaryEntry.id, isChecked) + } + adapter.setData(informViewModel.getSelectableCheckinItems()) + refreshSendButtonAndAllCheckbox() + } + selectAllContainer.setOnClickListener { selectAllCheckbox.isChecked = !selectAllCheckbox.isChecked } + dontSendButton.paintFlags = dontSendButton.paintFlags or Paint.UNDERLINE_TEXT_FLAG + dontSendButton.setOnClickListener { + if (informViewModel.hasSharedDP3TKeys) { + upload() + } else { + showFragment(NotThankYouFragment.newInstance(), R.id.inform_fragment_container) + } + } + sendButton.setOnClickListener { + informViewModel.hasSharedCheckins = true + upload() + } + refreshSendButtonAndAllCheckbox() + }.root + } + + private fun refreshSendButtonAndAllCheckbox() { + val hasSelectedCheckins = informViewModel.getSelectableCheckinItems().any { it.isSelected } + val allCheckinsSelected = informViewModel.getSelectableCheckinItems().all { it.isSelected } + binding.apply { + binding.sendButton.isEnabled = hasSelectedCheckins + if (!hasSelectedCheckins) selectAllCheckbox.isChecked = false + if (allCheckinsSelected) selectAllCheckbox.isChecked = true + } + } + + private fun upload() { + performUpload(onSuccess = { showFragment(ThankYouFragment.newInstance(), R.id.inform_fragment_container) }) + } + + override fun setLoadingViewVisible(isVisible: Boolean) { + with(binding) { + loadingView.isVisible = isVisible + sendButton.isEnabled = !isVisible + dontSendButton.isEnabled = !isVisible + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/ThankYouFragment.java b/app/src/main/java/ch/admin/bag/dp3t/inform/ThankYouFragment.java deleted file mode 100644 index 53daa1500..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/ThankYouFragment.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.inform; - -import android.os.Bundle; -import android.view.View; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import java.util.TimeZone; - -import ch.admin.bag.dp3t.R; -import ch.admin.bag.dp3t.storage.SecureStorage; -import ch.admin.bag.dp3t.util.DateUtils; - -public class ThankYouFragment extends Fragment { - - private SecureStorage secureStorage; - - public static ThankYouFragment newInstance() { - return new ThankYouFragment(); - } - - public ThankYouFragment() { - super(R.layout.fragment_thank_you); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - secureStorage = SecureStorage.getInstance(getContext()); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ((InformActivity) requireActivity()).allowBackButton(false); - - // Show the onset date in the thank you message - TextView thankYouInfoTextView = view.findViewById(R.id.inform_thank_you_text_info); - TextView onsetDateTextView = view.findViewById(R.id.inform_thank_you_text_onsetdate); - TextView stopInfectionChainsTextView = view.findViewById(R.id.inform_thank_you_text_stop_infection_chains); - - long oldestSharedKeyDateMillis = secureStorage.getPositiveReportOldestSharedKey(); - if (oldestSharedKeyDateMillis > 0L) { - String formattedDate = DateUtils.getFormattedDateWrittenMonth(oldestSharedKeyDateMillis, TimeZone.getTimeZone("UTC")); - String formattedOnsetDateText = - getString(R.string.inform_send_thankyou_text_onsetdate).replace("{ONSET_DATE}", formattedDate); - - thankYouInfoTextView.setText(R.string.inform_send_thankyou_text_onsetdate_info); - onsetDateTextView.setText(formattedOnsetDateText); - stopInfectionChainsTextView.setText(R.string.inform_send_thankyou_text_stop_infection_chains); - } else { - thankYouInfoTextView.setText(R.string.inform_send_thankyou_text); - onsetDateTextView.setVisibility(View.GONE); - stopInfectionChainsTextView.setVisibility(View.GONE); - } - - view.findViewById(R.id.inform_thank_you_button_continue).setOnClickListener(v -> { - getParentFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.slide_enter, R.anim.slide_exit, R.anim.slide_pop_enter, R.anim.slide_pop_exit) - .replace(R.id.inform_fragment_container, TracingStoppedFragment.newInstance()) - .commit(); - }); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/ThankYouFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/ThankYouFragment.kt new file mode 100644 index 000000000..c154e0818 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/ThankYouFragment.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.inform + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentThankYouBinding +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.util.DateUtils +import ch.admin.bag.dp3t.extensions.showFragment +import java.util.* + +class ThankYouFragment : Fragment() { + + companion object { + fun newInstance() = ThankYouFragment() + } + + private val informViewModel: InformViewModel by activityViewModels() + + private val secureStorage by lazy { SecureStorage.getInstance(requireContext()) } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentThankYouBinding.inflate(inflater).apply { + (requireActivity() as InformActivity).allowBackButton(false) + + informThankYouTextCheckins.isVisible = informViewModel.hasSharedCheckins + informThankYouTextInfo.isVisible = informViewModel.hasSharedDP3TKeys + informThankYouTextOnsetdate.isVisible = informViewModel.hasSharedDP3TKeys + + // Show the onset date in the thank you message + val oldestSharedKeyDateMillis = secureStorage.positiveReportOldestSharedKey + if (oldestSharedKeyDateMillis > 0L) { + val formattedDate = DateUtils.getFormattedDateWrittenMonth(oldestSharedKeyDateMillis, TimeZone.getTimeZone("UTC")) + val formattedOnsetDateText = + getString(R.string.inform_send_thankyou_text_onsetdate).replace("{ONSET_DATE}", formattedDate) + informThankYouTextInfo.setText(R.string.inform_send_thankyou_text_onsetdate_info) + informThankYouTextOnsetdate.text = formattedOnsetDateText + } else { + informThankYouTextInfo.setText(R.string.inform_send_thankyou_text) + informThankYouTextOnsetdate.visibility = View.GONE + informThankYouTextStopInfectionChains.visibility = View.GONE + } + + informThankYouButtonContinue.setOnClickListener { + showFragment(TracingStoppedFragment.newInstance(), R.id.inform_fragment_container) + } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/TraceKeyShareBaseFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/TraceKeyShareBaseFragment.kt new file mode 100644 index 000000000..ed1d8b361 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/TraceKeyShareBaseFragment.kt @@ -0,0 +1,126 @@ +package ch.admin.bag.dp3t.inform + +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.extensions.showFragment +import ch.admin.bag.dp3t.inform.models.Resource +import ch.admin.bag.dp3t.inform.models.Status +import ch.admin.bag.dp3t.networking.errors.InvalidCodeError +import ch.admin.bag.dp3t.networking.errors.ResponseError +import ch.admin.bag.dp3t.util.ENExceptionHelper +import ch.admin.bag.dp3t.viewmodel.TracingViewModel +import com.google.android.gms.common.api.ApiException +import org.dpppt.android.sdk.DP3T +import org.dpppt.android.sdk.PendingUploadTask +import org.dpppt.android.sdk.backend.ResponseCallback +import org.dpppt.android.sdk.internal.logger.Logger + +abstract class TraceKeyShareBaseFragment : Fragment() { + + + companion object { + private const val TAG = "TraceKeyShareBaseFragment" + } + + protected val informViewModel: InformViewModel by activityViewModels() + private val tracingViewModel: TracingViewModel by activityViewModels() + + abstract fun setLoadingViewVisible(isVisible: Boolean) + + protected fun showShareTEKsPopup(onSuccess: () -> Unit, onError: () -> Unit) { + DP3T.showShareTEKsPopup(requireActivity(), object : ResponseCallback { + override fun onSuccess(pendingUploadTask: PendingUploadTask) { + informViewModel.pendingUploadTask = pendingUploadTask + onSuccess() + } + + override fun onError(p0: Throwable?) { + onError() + } + }) + } + + protected fun askUserToEnableTracingIfNecessary(continuation: (isEnabled: Boolean) -> Unit) { + val isTracingEnabled = DP3T.isTracingEnabled(requireContext()) + if (!isTracingEnabled) { + showEnableTracingDialog(continuation) + } else { + continuation(true) + } + } + + protected fun performUpload(onSuccess: () -> Unit) { + informViewModel.performUpload().observe(viewLifecycleOwner) { + when (it.status) { + Status.LOADING -> { + setLoadingViewVisible(true) + } + Status.ERROR -> { + setLoadingViewVisible(false) + handleUploadException(it) + } + Status.SUCCESS -> { + setLoadingViewVisible(false) + onSuccess() + } + } + } + } + + private fun handleUploadException(error: Resource) { + if (error.data == null) return + when (error.exception) { + is ResponseError -> showErrorDialog(error.data, error.exception.statusCode.toString()) + is ApiException -> showErrorDialog(error.data, error.exception.statusCode.toString()) + is InvalidCodeError -> showErrorDialog(error.data) + else -> showErrorDialog(error.data) + } + error.exception?.printStackTrace() + } + + protected fun showErrorDialog(error: InformRequestError, addErrorCode: String? = null) { + val errorDialogBuilder = AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle) + .setMessage(error.errorMessage) + .setPositiveButton(R.string.android_button_ok) { _, _ -> + showFragment( + InformFragment.newInstance(false), + R.id.inform_fragment_container + ) + } + val errorCode = error.getErrorCode(addErrorCode) + val errorCodeView = layoutInflater.inflate(R.layout.view_dialog_error_code, view as ViewGroup?, false) as TextView + errorCodeView.text = errorCode + errorDialogBuilder.setView(errorCodeView) + errorDialogBuilder.show() + } + + private fun showEnableTracingDialog(continuation: (isEnabled: Boolean) -> Unit) { + AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.inform_tracing_enabled_explanation) + .setOnCancelListener { continuation(false) } + .setNegativeButton(R.string.meldung_in_app_alert_ignore_button) { _, _ -> continuation(false) } + .setPositiveButton(R.string.activate_tracing_button) { _, _ -> enableTracing(continuation) } + .show() + } + + private fun enableTracing(continuation: (isEnabled: Boolean) -> Unit) { + tracingViewModel.enableTracing(requireActivity(), + { continuation(true) }, + { e: Exception? -> + val message = ENExceptionHelper.getErrorMessage(e, activity) + Logger.e(TAG, message) + AlertDialog.Builder(requireActivity(), R.style.NextStep_AlertDialogStyle) + .setTitle(R.string.android_en_start_failure) + .setMessage(message) + .setOnDismissListener { continuation(false) } + .setPositiveButton(R.string.android_button_ok) { _, _ -> } + .show() + } + ) { continuation(false) } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/models/NetworkStatusModels.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/models/NetworkStatusModels.kt new file mode 100644 index 000000000..bffb8f1f5 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/models/NetworkStatusModels.kt @@ -0,0 +1,18 @@ +package ch.admin.bag.dp3t.inform.models + +enum class Status { + SUCCESS, + ERROR, + LOADING +} + +data class Resource(val status: Status, val data: T?, val exception: Throwable?) { + companion object { + fun success(data: T): Resource = Resource(status = Status.SUCCESS, data = data, exception = null) + + fun error(data: T?, exception: Throwable): Resource = + Resource(status = Status.ERROR, data = data, exception = exception) + + fun loading(data: T?): Resource = Resource(status = Status.LOADING, data = data, exception = null) + } +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/models/SelectableCheckinItem.kt b/app/src/main/java/ch/admin/bag/dp3t/inform/models/SelectableCheckinItem.kt new file mode 100644 index 000000000..a12ae1d14 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/models/SelectableCheckinItem.kt @@ -0,0 +1,5 @@ +package ch.admin.bag.dp3t.inform.models + +import ch.admin.bag.dp3t.checkin.models.DiaryEntry + +data class SelectableCheckinItem(val diaryEntry: DiaryEntry, var isSelected: Boolean) \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/infotab/InfoTabFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/infotab/InfoTabFragment.kt new file mode 100644 index 000000000..17d29baf6 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/infotab/InfoTabFragment.kt @@ -0,0 +1,142 @@ +package ch.admin.bag.dp3t.infotab + +import android.content.Intent +import android.graphics.Paint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.checkinflow.CheckOutFragment +import ch.admin.bag.dp3t.databinding.FragmentInfoTabBinding +import ch.admin.bag.dp3t.extensions.showFragment +import ch.admin.bag.dp3t.inform.InformActivity +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.travel.TravelFragment +import ch.admin.bag.dp3t.travel.TravelUtils +import ch.admin.bag.dp3t.util.UrlUtil +import ch.admin.bag.dp3t.viewmodel.TracingViewModel +import ch.admin.bag.dp3t.whattodo.WtdInfolineAccessabilityDialogFragment +import ch.admin.bag.dp3t.whattodo.WtdPositiveTestFragment +import ch.admin.bag.dp3t.whattodo.WtdSymptomsFragment + +class InfoTabFragment : Fragment() { + + companion object { + @JvmStatic + fun newInstance() = InfoTabFragment() + } + + private lateinit var binding: FragmentInfoTabBinding + private val secureStorage by lazy { SecureStorage.getInstance(requireContext()) } + private val tracingViewModel: TracingViewModel by activityViewModels() + private val crowdNotifierViewModel: CrowdNotifierViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentInfoTabBinding.inflate(inflater).apply { + frameCardSymptoms.root.setOnClickListener { showFragment(WtdSymptomsFragment.newInstance()) } + tracingViewModel.appStatusLiveData.observe(viewLifecycleOwner) { tracingStatusInterface -> + frameCardTest.root.isVisible = !tracingStatusInterface.isReportedAsInfected + } + } + setupTravelCard() + setupPositiveTestCard() + return binding.root + } + + private fun setupPositiveTestCard() { + fillContentFromConfigServer() + binding.frameCardTest.apply { + wtdMoreAboutCovidcodeButton.paintFlags = wtdMoreAboutCovidcodeButton.paintFlags or Paint.UNDERLINE_TEXT_FLAG + wtdMoreAboutCovidcodeButton.setOnClickListener { + showFragment(WtdPositiveTestFragment.newInstance()) + } + wtdInformButton.setOnClickListener { + if (crowdNotifierViewModel.isCheckedIn.value == true) { + showCannotEnterCovidcodeWhileCheckedInDialog() + } else { + startActivity(Intent(activity, InformActivity::class.java)) + } + } + } + } + + private fun showCannotEnterCovidcodeWhileCheckedInDialog() { + AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.error_cannot_enter_covidcode_while_checked_in) + .setPositiveButton(R.string.checkout_button_title) { _, _ -> showCheckOutFragment() } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .create() + .show() + } + + private fun showCheckOutFragment() { + requireActivity().supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.modal_slide_enter, R.anim.modal_slide_exit, R.anim.modal_pop_enter, R.anim.modal_pop_exit) + .replace(R.id.main_fragment_container, CheckOutFragment.newInstance()) + .addToBackStack(CheckOutFragment::class.java.canonicalName) + .commit() + } + + private fun fillContentFromConfigServer() { + val textModel = secureStorage.getWhatToDoPositiveTestTexts(getString(R.string.language_key)) ?: return + binding.frameCardTest.apply { + wtdInformBoxSupertitle.text = textModel.enterCovidcodeBoxSupertitle + wtdInformBoxTitle.text = textModel.enterCovidcodeBoxTitle + wtdInformBoxText.text = textModel.enterCovidcodeBoxText + wtdInformButton.text = textModel.enterCovidcodeBoxButtonTitle + wtdInformInfobox.isVisible = textModel.infoBox != null + textModel.infoBox?.let { infoBox -> + wtdInformInfoboxTitle.text = infoBox.title + wtdInformInfoboxMsg.text = infoBox.msg + if (infoBox.url != null && infoBox.urlTitle != null) { + wtdInformInfoboxLinkText.text = infoBox.urlTitle + wtdInformInfoboxLinkLayout.setOnClickListener { UrlUtil.openUrl(it.context, infoBox.url) } + wtdInformInfoboxLinkLayout.isVisible = true + if (infoBox.url.startsWith("tel://")) { + wtdInformInfoboxLinkIcon.setImageResource(R.drawable.ic_phone) + } else { + wtdInformInfoboxLinkIcon.setImageResource(R.drawable.ic_launch) + } + } else { + wtdInformInfoboxLinkLayout.isVisible = false + } + if (infoBox.hearingImpairedInfo != null) { + wtdInformInfoboxLinkIcon.setImageResource(R.drawable.ic_phone) + wtdInformInfoboxLinkHearingImpaired.setOnClickListener { showWtdInfolineAccessabilityDialogFragment(infoBox.hearingImpairedInfo) } + wtdInformInfoboxLinkHearingImpaired.isVisible = true + } else { + wtdInformInfoboxLinkIcon.setImageResource(R.drawable.ic_launch) + wtdInformInfoboxLinkHearingImpaired.isVisible = false + } + } + } + } + + private fun showWtdInfolineAccessabilityDialogFragment(hearingImpairedInfo: String) { + requireActivity().supportFragmentManager.beginTransaction() + .add( + WtdInfolineAccessabilityDialogFragment.newInstance(hearingImpairedInfo), + WtdInfolineAccessabilityDialogFragment::class.java.canonicalName + ) + .commit() + } + + + private fun setupTravelCard() { + binding.cardTravel.apply { + cardTravel.setOnClickListener { showFragment(TravelFragment.newInstance()) } + val countries: List = secureStorage.interopCountries + cardTravel.isVisible = countries.isNotEmpty() + if (countries.isNotEmpty()) { + TravelUtils.inflateFlagFlow(travelFlagsFlow, countries) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeRepository.kt b/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeRepository.kt index 3720e16a8..f1c957bbd 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeRepository.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeRepository.kt @@ -10,24 +10,20 @@ package ch.admin.bag.dp3t.networking import android.content.Context -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope import ch.admin.bag.dp3t.BuildConfig import ch.admin.bag.dp3t.networking.errors.InvalidCodeError import ch.admin.bag.dp3t.networking.errors.ResponseError import ch.admin.bag.dp3t.networking.models.AuthenticationCodeRequestModel -import ch.admin.bag.dp3t.networking.models.AuthenticationCodeResponseModel +import ch.admin.bag.dp3t.networking.models.AuthenticationCodeResponseModelV2 +import ch.admin.bag.dp3t.networking.models.OnsetResponse import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Interceptor import okhttp3.OkHttpClient import org.dpppt.android.sdk.DP3T -import org.dpppt.android.sdk.backend.ResponseCallback import org.dpppt.android.sdk.backend.UserAgentInterceptor -import org.dpppt.android.sdk.internal.logger.Logger import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import java.io.IOException class AuthCodeRepository(context: Context) { @@ -61,43 +57,28 @@ class AuthCodeRepository(context: Context) { authCodeService = retrofit.create(AuthCodeService::class.java) } - fun getAccessToken( - authenticationCode: AuthenticationCodeRequestModel, - callbackListener: ResponseCallback, - lifecycleOwner: LifecycleOwner - ) { - lifecycleOwner.lifecycleScope.launch { - withContext(Dispatchers.IO) { - try { - val response = authCodeService.getAccessToken(authenticationCode) - if (response.isSuccessful) { - withContext(Dispatchers.Main) { - callbackListener.onSuccess(response.body()) - } - } else { - withContext(Dispatchers.Main) { - if (response.code() == 404) { - callbackListener.onError(InvalidCodeError()) - } else { - callbackListener.onError(ResponseError(response.raw())) - } - } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Logger.e(TAG, "getAccessToken", e) - callbackListener.onError(e) - } + suspend fun getAccessToken(authCode: AuthenticationCodeRequestModel): AuthenticationCodeResponseModelV2 = + withContext(Dispatchers.IO) { + val response = authCodeService.getAccessTokenV2(authCode) + if (!response.isSuccessful) { + if (response.code() == 404) { + throw InvalidCodeError() + } else { + throw ResponseError(response.raw()) } } + return@withContext response.body() ?: throw ResponseError(response.raw()) } - } - @Throws(IOException::class, ResponseError::class) - suspend fun getAccessTokenSync(authenticationCode: AuthenticationCodeRequestModel): AuthenticationCodeResponseModel = - withContext(Dispatchers.IO) { - val response = authCodeService.getAccessToken(authenticationCode) - if (!response.isSuccessful) throw ResponseError(response.raw()) - return@withContext response.body() ?: throw ResponseError(response.raw()) + suspend fun getOnsetDate(authCode: AuthenticationCodeRequestModel): OnsetResponse { + val response = authCodeService.getOnsetDate(authCode) + if (!response.isSuccessful) { + if (response.code() == 404) { + throw InvalidCodeError() + } else { + throw ResponseError(response.raw()) + } } + return response.body() ?: throw ResponseError(response.raw()) + } } \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeService.kt b/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeService.kt index 28fa2af1d..665d84d90 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeService.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/networking/AuthCodeService.kt @@ -11,6 +11,8 @@ package ch.admin.bag.dp3t.networking import ch.admin.bag.dp3t.networking.models.AuthenticationCodeRequestModel import ch.admin.bag.dp3t.networking.models.AuthenticationCodeResponseModel +import ch.admin.bag.dp3t.networking.models.AuthenticationCodeResponseModelV2 +import ch.admin.bag.dp3t.networking.models.OnsetResponse import retrofit2.Response import retrofit2.http.Body import retrofit2.http.Headers @@ -18,6 +20,11 @@ import retrofit2.http.POST interface AuthCodeService { @Headers("accept: */*", "content-type: application/json") - @POST("v1/onset") - suspend fun getAccessToken(@Body code: AuthenticationCodeRequestModel): Response + @POST("v2/onset") + suspend fun getAccessTokenV2(@Body code: AuthenticationCodeRequestModel): Response + + @Headers("accept: */*", "content-type: application/json") + @POST("v2/onset/date") + suspend fun getOnsetDate(@Body code: AuthenticationCodeRequestModel): Response + } \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/ConfigWorker.kt b/app/src/main/java/ch/admin/bag/dp3t/networking/ConfigWorker.kt index 8ffc7bd2f..5c3330433 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/ConfigWorker.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/networking/ConfigWorker.kt @@ -18,14 +18,21 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.work.* import ch.admin.bag.dp3t.BuildConfig +import ch.admin.bag.dp3t.MainActivity import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.debug.DebugFragment import ch.admin.bag.dp3t.networking.errors.ResponseError +import ch.admin.bag.dp3t.onboarding.OnboardingSlidePageAdapter.Companion.CHECKIN_UPDATE_BOARDING_VERSION import ch.admin.bag.dp3t.storage.SecureStorage import ch.admin.bag.dp3t.util.NotificationUtil import org.dpppt.android.sdk.DP3T import org.dpppt.android.sdk.backend.SignatureException +import org.dpppt.android.sdk.internal.history.HistoryDatabase +import org.dpppt.android.sdk.internal.history.HistoryEntry +import org.dpppt.android.sdk.internal.history.HistoryEntryType import org.dpppt.android.sdk.internal.logger.Logger import java.io.IOException +import java.time.LocalDateTime import java.util.concurrent.TimeUnit class ConfigWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { @@ -79,6 +86,8 @@ class ConfigWorker(context: Context, workerParams: WorkerParameters) : Coroutine secureStorage.hasInfobox = false } + secureStorage.setTestInformationUrls(config.testInformationUrls) + secureStorage.testLocations = config.testLocations secureStorage.interopCountries = config.interOpsCountries @@ -89,6 +98,15 @@ class ConfigWorker(context: Context, workerParams: WorkerParameters) : Coroutine } } else { cancelNotification(context) + + if (config.isCheckInUpdateNotificationEnabled && !secureStorage.checkInUpdateNotificationShown + && secureStorage.onboardingCompleted && secureStorage.lastShownUpdateBoardingVersion < CHECKIN_UPDATE_BOARDING_VERSION + && !secureStorage.forceUpdateLiveData.hasObservers() + && (8..19).contains(LocalDateTime.now().hour) + ) { + secureStorage.checkInUpdateNotificationShown = true + showCheckInUpdateNotification(context); + } } } @@ -109,6 +127,14 @@ class ConfigWorker(context: Context, workerParams: WorkerParameters) : Coroutine .setAutoCancel(true) .build() val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (DebugFragment.EXISTS) { + HistoryDatabase.getInstance(context).addEntry( + HistoryEntry( + HistoryEntryType.NOTIFICATION, "Showing update required notification", false, + System.currentTimeMillis() + ) + ) + } notificationManager.notify(NotificationUtil.NOTIFICATION_ID_UPDATE, notification) } @@ -116,6 +142,38 @@ class ConfigWorker(context: Context, workerParams: WorkerParameters) : Coroutine val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(NotificationUtil.NOTIFICATION_ID_UPDATE) } + + private fun showCheckInUpdateNotification(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationUtil.createNotificationChannel(context) + } + + val intent = Intent(context, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + val notification = NotificationCompat.Builder(context, NotificationUtil.NOTIFICATION_CHANNEL_ID) + .setContentTitle(context.getString(R.string.update_notification_checkin_feature_title)) + .setContentText(context.getString(R.string.update_notification_checkin_feature_text)) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.update_notification_checkin_feature_text)) + ) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setSmallIcon(R.drawable.ic_begegnungen) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (DebugFragment.EXISTS) { + HistoryDatabase.getInstance(context).addEntry( + HistoryEntry( + HistoryEntryType.NOTIFICATION, "Showing update notification for checkin feature", false, + System.currentTimeMillis() + ) + ) + } + notificationManager.notify(NotificationUtil.NOTIFICATION_ID_CHECKIN_UPDATE, notification) + } } override suspend fun doWork(): Result { diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/FakeWorker.kt b/app/src/main/java/ch/admin/bag/dp3t/networking/FakeWorker.kt index 475c05488..24b4c21f7 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/FakeWorker.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/networking/FakeWorker.kt @@ -12,6 +12,7 @@ package ch.admin.bag.dp3t.networking import android.content.Context import androidx.work.* import ch.admin.bag.dp3t.BuildConfig +import ch.admin.bag.dp3t.checkin.networking.UserUploadRepository import ch.admin.bag.dp3t.networking.models.AuthenticationCodeRequestModel import ch.admin.bag.dp3t.storage.SecureStorage import ch.admin.bag.dp3t.util.ExponentialDistribution @@ -20,9 +21,11 @@ import org.dpppt.android.sdk.DP3T import org.dpppt.android.sdk.DP3TKotlin import org.dpppt.android.sdk.internal.logger.Logger import org.dpppt.android.sdk.models.ExposeeAuthMethodAuthorization +import java.security.SecureRandom import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.max +import kotlin.math.roundToInt class FakeWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { @@ -38,6 +41,7 @@ class FakeWorker(context: Context, workerParams: WorkerParameters) : CoroutineWo private const val FACTOR_HOUR_MILLIS = 60 * 60 * 1000L private const val FACTOR_DAY_MILLIS = 24 * FACTOR_HOUR_MILLIS private const val MAX_DELAY_HOURS: Long = 48 + private const val MAX_USER_INTERACTION_DELAY: Long = 3 * 60 * 1000L private val isWorkInProgress = AtomicBoolean(false) @@ -145,11 +149,23 @@ class FakeWorker(context: Context, workerParams: WorkerParameters) : CoroutineWo private suspend fun executeFakeRequest(context: Context): Boolean { return try { val authCodeRepository = AuthCodeRepository(context) - val accessTokenResponse = authCodeRepository.getAccessTokenSync(AuthenticationCodeRequestModel(FAKE_AUTH_CODE, 1)) - val accessToken = accessTokenResponse.accessToken - DP3TKotlin.sendFakeInfectedRequest(context, ExposeeAuthMethodAuthorization(getAuthorizationHeader(accessToken))) + + //Execute Onset Date Request + authCodeRepository.getOnsetDate(AuthenticationCodeRequestModel(FAKE_AUTH_CODE, 1)) + + val timeBetweenOnsetAndUploadRequest = clock.getUserInteractionDelay() + delay(timeBetweenOnsetAndUploadRequest.toLong()) + + //Execute Access Token Request + val accessTokenResponse = authCodeRepository.getAccessToken(AuthenticationCodeRequestModel(FAKE_AUTH_CODE, 1)) + val dp3tAccessToken = accessTokenResponse.dp3TAccessToken.accessToken + //Execute DP3T Infected Request + DP3TKotlin.sendFakeInfectedRequest(context, ExposeeAuthMethodAuthorization(getAuthorizationHeader(dp3tAccessToken))) + //Execute Checkin UserUpload Request + val checkinAccessToken = accessTokenResponse.checkInAccessToken.accessToken + UserUploadRepository().fakeUserUpload(timeBetweenOnsetAndUploadRequest, getAuthorizationHeader(checkinAccessToken)) true - } catch (e: Exception) { + } catch (e: Throwable) { Logger.e(TAG, "fake request failed", e) false } @@ -162,6 +178,7 @@ class FakeWorker(context: Context, workerParams: WorkerParameters) : CoroutineWo interface Clock { fun syncInterval(): Long fun currentTimeMillis(): Long + fun getUserInteractionDelay(): Int } class ClockImpl : Clock { @@ -173,6 +190,10 @@ class FakeWorker(context: Context, workerParams: WorkerParameters) : CoroutineWo override fun currentTimeMillis(): Long { return System.currentTimeMillis() } + + override fun getUserInteractionDelay(): Int { + return (SecureRandom().nextDouble() * MAX_USER_INTERACTION_DELAY).roundToInt() + } } } diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthCodeModels.kt b/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthCodeModels.kt new file mode 100644 index 000000000..94d46ca35 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthCodeModels.kt @@ -0,0 +1,15 @@ +package ch.admin.bag.dp3t.networking.models + +import androidx.annotation.Keep + +class AuthenticationCodeRequestModel(@field:Keep private val authorizationCode: String, @field:Keep private val fake: Int) + +data class AuthenticationCodeResponseModelV2( + val checkInAccessToken: AuthenticationCodeResponseModel, + val dp3TAccessToken: AuthenticationCodeResponseModel +) + +class AuthenticationCodeResponseModel(val accessToken: String) + + +data class OnsetResponse(val onset: String) diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthenticationCodeRequestModel.java b/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthenticationCodeRequestModel.java deleted file mode 100644 index 4435beba4..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthenticationCodeRequestModel.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.networking.models; - -import androidx.annotation.Keep; - -public class AuthenticationCodeRequestModel { - - @Keep - private String authorizationCode; - @Keep - private int fake; - - public AuthenticationCodeRequestModel(String authorizationCode, int fake) { - this.authorizationCode = authorizationCode; - this.fake = fake; - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthenticationCodeResponseModel.java b/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthenticationCodeResponseModel.java deleted file mode 100644 index ddcd7570e..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/models/AuthenticationCodeResponseModel.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.networking.models; - -public class AuthenticationCodeResponseModel { - - private String accessToken; - - public String getAccessToken() { - return accessToken; - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/networking/models/ConfigResponseModel.java b/app/src/main/java/ch/admin/bag/dp3t/networking/models/ConfigResponseModel.java index 9a2938ca1..205fe5f67 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/networking/models/ConfigResponseModel.java +++ b/app/src/main/java/ch/admin/bag/dp3t/networking/models/ConfigResponseModel.java @@ -20,6 +20,8 @@ public class ConfigResponseModel { private SdkConfigModel androidGaenSdkConfig; private Map> testLocations; private List interOpsCountries; + private Map testInformationUrls; + private boolean checkInUpdateNotificationEnabled; public boolean getDoForceUpdate() { return forceUpdate; @@ -50,4 +52,12 @@ public List getInterOpsCountries() { return interOpsCountries; } + public Map getTestInformationUrls() { + return testInformationUrls; + } + + public boolean isCheckInUpdateNotificationEnabled() { + return checkInUpdateNotificationEnabled; + } + } diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivity.java b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivity.java deleted file mode 100644 index 530ef75ac..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivity.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.onboarding; - -import android.animation.Animator; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; -import androidx.viewpager2.widget.ViewPager2; - -import org.dpppt.android.sdk.DP3T; - -import ch.admin.bag.dp3t.R; - -public class OnboardingActivity extends FragmentActivity { - - private static final int SHOW_SPLASHBOARDING_MILLIS = 3000; - - private View splashboarding; - private ViewPager2 viewPager; - private FragmentStateAdapter pagerAdapter; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_onboarding); - - splashboarding = findViewById(R.id.splashboarding); - viewPager = findViewById(R.id.pager); - viewPager.setUserInputEnabled(false); - pagerAdapter = new OnboardingSlidePageAdapter(this); - viewPager.setAdapter(pagerAdapter); - - splashboarding - .postDelayed(() -> { - splashboarding.setAlpha(1); - splashboarding.animate().alpha(0).setDuration(200).withLayer().setListener(new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) {} - - @Override - public void onAnimationEnd(Animator animator) { - splashboarding.setVisibility(View.GONE); - } - - @Override - public void onAnimationCancel(Animator animator) {} - - @Override - public void onAnimationRepeat(Animator animator) {} - }).start(); - }, SHOW_SPLASHBOARDING_MILLIS); - } - - public void continueToNextPage() { - int currentItem = viewPager.getCurrentItem(); - if (currentItem < pagerAdapter.getItemCount() - 1) { - viewPager.setCurrentItem(currentItem + 1, true); - } else { - setResult(RESULT_OK); - finish(); - overridePendingTransition(R.anim.fragment_open_enter, R.anim.fragment_open_exit); - } - } - - public void abortOnboarding() { - setResult(RESULT_CANCELED); - finish(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - DP3T.onActivityResult(this, requestCode, resultCode, data); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivity.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivity.kt new file mode 100644 index 000000000..fd8ef22fd --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivity.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.onboarding + +import android.animation.Animator +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import ch.admin.bag.dp3t.R +import org.dpppt.android.sdk.DP3T + +private const val SHOW_SPLASHBOARDING_MILLIS = 3000 + +class OnboardingActivity : FragmentActivity() { + + companion object { + const val ARG_ONBOARDING_TYPE = "ARG_ONBOARDING_TYPE" + const val ARG_INSTANT_APP_URL = "ARG_INSTANT_APP_URL" + } + + private lateinit var splashboarding: View + private lateinit var viewPager: ViewPager2 + private lateinit var pagerAdapter: FragmentStateAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_onboarding) + + splashboarding = findViewById(R.id.splashboarding) + viewPager = findViewById(R.id.pager) + + viewPager.isUserInputEnabled = false + val onboardingType = intent.getSerializableExtra(ARG_ONBOARDING_TYPE) as OnboardingType + pagerAdapter = OnboardingSlidePageAdapter(this, onboardingType) + viewPager.adapter = pagerAdapter + if (onboardingType == OnboardingType.NORMAL) { + splashboarding.postDelayed({ + splashboarding.alpha = 1f + splashboarding.animate().alpha(0f).setDuration(200).withLayer().setListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animator: Animator) {} + override fun onAnimationEnd(animator: Animator) { + splashboarding.visibility = View.GONE + } + + override fun onAnimationCancel(animator: Animator) {} + override fun onAnimationRepeat(animator: Animator) {} + }).start() + }, SHOW_SPLASHBOARDING_MILLIS.toLong()) + } else { + splashboarding.alpha = 0f + } + } + + fun continueToNextPage() { + val currentItem = viewPager.currentItem + if (currentItem < pagerAdapter.itemCount - 1) { + viewPager.setCurrentItem(currentItem + 1, true) + } else { + val resultIntent = Intent() + .putExtra(ARG_ONBOARDING_TYPE, intent.getSerializableExtra(ARG_ONBOARDING_TYPE)) + .putExtra(ARG_INSTANT_APP_URL, intent.getStringExtra(ARG_INSTANT_APP_URL)) + setResult(RESULT_OK, resultIntent) + finish() + overridePendingTransition(R.anim.fragment_open_enter, R.anim.fragment_open_exit) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + DP3T.onActivityResult(this, requestCode, resultCode, data) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivityResultContract.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivityResultContract.kt new file mode 100644 index 000000000..71b95f2e4 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingActivityResultContract.kt @@ -0,0 +1,34 @@ +package ch.admin.bag.dp3t.onboarding + +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContract +import ch.admin.bag.dp3t.onboarding.OnboardingActivity.Companion.ARG_INSTANT_APP_URL + +class OnboardingActivityResultContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: OnboardingActivityArgs): Intent { + return Intent(context, OnboardingActivity::class.java).apply { + putExtra(OnboardingActivity.ARG_ONBOARDING_TYPE, input.onboardingType) + putExtra(ARG_INSTANT_APP_URL, input.instantAppUrl) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): OnboardingActivityResult? { + val onboardingType = intent?.getSerializableExtra(OnboardingActivity.ARG_ONBOARDING_TYPE) as OnboardingType? + if (onboardingType == null) { + return null + } + val instantAppUrl = intent?.getStringExtra(ARG_INSTANT_APP_URL) + return OnboardingActivityResult(ActivityResult(resultCode, intent), onboardingType, instantAppUrl) + } +} + +data class OnboardingActivityArgs(val onboardingType: OnboardingType, val instantAppUrl: String? = null) + +data class OnboardingActivityResult( + val activityResult: ActivityResult, + val onboardingType: OnboardingType, + val instantAppUrl: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingContentFragment.java b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingContentFragment.java deleted file mode 100644 index da3d194d2..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingContentFragment.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.onboarding; - -import android.content.res.ColorStateList; -import android.os.Bundle; -import android.view.View; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; - -import ch.admin.bag.dp3t.R; - -public class OnboardingContentFragment extends Fragment { - - private static final String ARG_RES_TITLE = "RES_TITLE"; - private static final String ARG_RES_SUBTITLE = "RES_SUBTITLE"; - private static final String ARG_RES_DESCRIPTION_1 = "RES_DESCRIPTION_1"; - private static final String ARG_RES_DESCRIPTION_2 = "RES_DESCRIPTION_2"; - private static final String ARG_RES_DESCR_ICON_1 = "ARG_RES_DESCR_ICON_1"; - private static final String ARG_RES_DESCR_ICON_2 = "ARG_RES_DESCR_ICON_2"; - private static final String ARG_RES_ILLUSTRATION = "RES_ILLUSTRATION"; - private static final String ARG_STYLE_GREEN = "ARG_STYLE_GREEN"; - - public static OnboardingContentFragment newInstance(@StringRes int title, @StringRes int subtitle, - @DrawableRes int illustration, @StringRes int description1, @DrawableRes int iconDescription1, - @StringRes int description2, @DrawableRes int iconDescription2, boolean greenStyle) { - Bundle args = new Bundle(); - args.putInt(ARG_RES_TITLE, title); - args.putInt(ARG_RES_SUBTITLE, subtitle); - args.putInt(ARG_RES_ILLUSTRATION, illustration); - args.putInt(ARG_RES_DESCR_ICON_1, iconDescription1); - args.putInt(ARG_RES_DESCRIPTION_1, description1); - args.putInt(ARG_RES_DESCR_ICON_2, iconDescription2); - args.putInt(ARG_RES_DESCRIPTION_2, description2); - args.putBoolean(ARG_STYLE_GREEN, greenStyle); - OnboardingContentFragment fragment = new OnboardingContentFragment(); - fragment.setArguments(args); - return fragment; - } - - public OnboardingContentFragment() { - super(R.layout.fragment_onboarding_content); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - Bundle args = requireArguments(); - - ((TextView) view.findViewById(R.id.onboarding_title)).setText(args.getInt(ARG_RES_TITLE)); - - TextView subtitle = view.findViewById(R.id.onboarding_subtitle); - subtitle.setText(args.getInt(ARG_RES_SUBTITLE)); - if (args.getBoolean(ARG_STYLE_GREEN)) - subtitle.setTextColor(getResources().getColor(R.color.green_main, null)); - - ((ImageView) view.findViewById(R.id.onboarding_illustration)).setImageResource(args.getInt(ARG_RES_ILLUSTRATION)); - - ImageView icon1 = view.findViewById(R.id.onboarding_description_1_icon); - icon1.setImageResource(args.getInt(ARG_RES_DESCR_ICON_1)); - if (args.getBoolean(ARG_STYLE_GREEN)) - icon1.setImageTintList(ColorStateList.valueOf(getResources().getColor(R.color.green_main, null))); - ((TextView) view.findViewById(R.id.onboarding_description_1)).setText(args.getInt(ARG_RES_DESCRIPTION_1)); - - ImageView icon2 = view.findViewById(R.id.onboarding_description_2_icon); - icon2.setImageResource(args.getInt(ARG_RES_DESCR_ICON_2)); - if (args.getBoolean(ARG_STYLE_GREEN)) - icon2.setImageTintList(ColorStateList.valueOf(getResources().getColor(R.color.green_main, null))); - ((TextView) view.findViewById(R.id.onboarding_description_2)).setText(args.getInt(ARG_RES_DESCRIPTION_2)); - - Button continueButton = view.findViewById(R.id.onboarding_continue_button); - continueButton.setOnClickListener(v -> { - ((OnboardingActivity) getActivity()).continueToNextPage(); - }); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingContentFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingContentFragment.kt new file mode 100644 index 000000000..227ca308b --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingContentFragment.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.onboarding + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import ch.admin.bag.dp3t.databinding.FragmentOnboardingContentBinding +import ch.admin.bag.dp3t.databinding.ItemIconWithTextBinding + +private const val ARG_RES_TITLE = "ARG_RES_TITLE" +private const val ARG_RES_SUBTITLE = "ARG_RES_SUBTITLE" +private const val ARG_RES_DESCRIPTIONS = "ARG_RES_DESCRIPTIONS" +private const val ARG_RES_DESCR_ICONS = "ARG_RES_DESCR_ICONS" +private const val ARG_RES_ILLUSTRATION = "ARG_RES_ILLUSTRATION" + +class OnboardingContentFragment : Fragment() { + + companion object { + + fun newInstance( + @StringRes title: Int, + @StringRes subtitle: Int, + @DrawableRes illustration: Int, + @StringRes descriptions: IntArray, + @DrawableRes icons: IntArray + ) = OnboardingContentFragment().apply { + arguments = bundleOf( + ARG_RES_TITLE to title, + ARG_RES_SUBTITLE to subtitle, + ARG_RES_ILLUSTRATION to illustration, + ARG_RES_DESCRIPTIONS to descriptions, + ARG_RES_DESCR_ICONS to icons + ) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return FragmentOnboardingContentBinding.inflate(inflater).apply { + val args = requireArguments() + onboardingTitle.setText(args.getInt(ARG_RES_TITLE)) + onboardingSubtitle.setText(args.getInt(ARG_RES_SUBTITLE)) + onboardingIllustration.setImageResource(args.getInt(ARG_RES_ILLUSTRATION)) + + val descriptions = args.getIntArray(ARG_RES_DESCRIPTIONS) ?: intArrayOf() + val icons = args.getIntArray(ARG_RES_DESCR_ICONS) ?: intArrayOf() + for ((description, icon) in descriptions.zip(icons)) { + ItemIconWithTextBinding.inflate(inflater, descriptionContainer, true).apply { + this.icon.setImageResource(icon) + this.text.setText(description) + } + } + + onboardingContinueButton.setOnClickListener { (activity as OnboardingActivity?)!!.continueToNextPage() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingDisclaimerFragment.java b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingDisclaimerFragment.java index 56c0c1e28..7fa55855d 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingDisclaimerFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingDisclaimerFragment.java @@ -20,11 +20,6 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; -import java.text.SimpleDateFormat; -import java.util.Locale; -import java.util.TimeZone; - -import ch.admin.bag.dp3t.BuildConfig; import ch.admin.bag.dp3t.R; import ch.admin.bag.dp3t.util.AssetUtil; import ch.admin.bag.dp3t.util.UlTagHandler; @@ -44,13 +39,6 @@ public OnboardingDisclaimerFragment() { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy", Locale.ENGLISH); - sdf.setTimeZone(TimeZone.getTimeZone("Europe/Zurich")); - String versionText = getString(R.string.onboarding_disclaimer_app_version) + " " + BuildConfig.VERSION_NAME + "\n" + - getString(R.string.onboarding_disclaimer_release_version) + " " + sdf.format(BuildConfig.BUILD_TIME); - TextView versionInfo = view.findViewById(R.id.onboarding_disclaimer_version_info); - versionInfo.setText(versionText); - TextView termsOfUseTextview = view.findViewById(R.id.terms_of_use_textview); TextView dataProtectionTextView = view.findViewById(R.id.data_protection_textview); termsOfUseTextview.setText(Html.fromHtml(AssetUtil.getTermsOfUse(getContext()), null, new UlTagHandler())); diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingFinishedFragment.java b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingFinishedFragment.java deleted file mode 100644 index b2cb2d622..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingFinishedFragment.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.onboarding; - -import android.os.Bundle; -import android.view.View; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import ch.admin.bag.dp3t.R; - -public class OnboardingFinishedFragment extends Fragment { - - public static OnboardingFinishedFragment newInstance() { - return new OnboardingFinishedFragment(); - } - - public OnboardingFinishedFragment() { - super(R.layout.fragment_onboarding_finished); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - view.findViewById(R.id.onboarding_continue_button) - .setOnClickListener(v -> { - ((OnboardingActivity) getActivity()).continueToNextPage(); - }); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingFinishedFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingFinishedFragment.kt new file mode 100644 index 000000000..46408d634 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingFinishedFragment.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.onboarding + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentOnboardingFinishedBinding + +private const val ARG_ONBOARDING_TYPE = "ARG_ONBOARDING_TYPE" + +class OnboardingFinishedFragment : Fragment() { + + companion object { + fun newInstance(onboardingType: OnboardingType) = OnboardingFinishedFragment().apply { + arguments = bundleOf(ARG_ONBOARDING_TYPE to onboardingType) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentOnboardingFinishedBinding.inflate(inflater).apply { + val onboardingType = requireArguments().getSerializable(ARG_ONBOARDING_TYPE) as OnboardingType + if (onboardingType == OnboardingType.NON_INSTANT_PART) { + onboardingTitle.setText(R.string.partial_onboarding_done_title) + onboardingText.setText(R.string.partial_onboarding_done_text) + onboardingContinueButton.setText(R.string.partial_onboarding_box_action) + } + onboardingContinueButton.setOnClickListener { (activity as OnboardingActivity).continueToNextPage() } + }.root + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingGaenPermissionFragment.java b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingGaenPermissionFragment.java index ef2916008..aad980d4d 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingGaenPermissionFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingGaenPermissionFragment.java @@ -10,9 +10,11 @@ package ch.admin.bag.dp3t.onboarding; import android.content.Context; +import android.graphics.Paint; import android.os.Bundle; import android.view.View; import android.widget.Button; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; @@ -32,17 +34,23 @@ public class OnboardingGaenPermissionFragment extends Fragment { private static final String TAG = "OnboardingGaen"; private static final String STATE_USER_ACTIVE = "STATE_USER_ACTIVE"; + private static final String ARG_ONBOARDING_TYPE = "ARG_ONBOARDING_TYPE"; private Button activateButton; private Button continueButton; + private TextView dontActivateButton; private AlertDialog playServicesUpdateDialog; private boolean wasUserActive = false; private boolean startedService = false; - public static OnboardingGaenPermissionFragment newInstance() { - return new OnboardingGaenPermissionFragment(); + public static OnboardingGaenPermissionFragment newInstance(OnboardingType onboardingType) { + OnboardingGaenPermissionFragment fragment = new OnboardingGaenPermissionFragment(); + Bundle arguments = new Bundle(); + arguments.putSerializable(ARG_ONBOARDING_TYPE, onboardingType); + fragment.setArguments(arguments); + return fragment; } public OnboardingGaenPermissionFragment() { @@ -65,9 +73,14 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat wasUserActive = true; }); continueButton = view.findViewById(R.id.onboarding_gaen_continue_button); - continueButton.setOnClickListener(v -> { - ((OnboardingActivity) requireActivity()).continueToNextPage(); - }); + continueButton.setOnClickListener(v -> ((OnboardingActivity) requireActivity()).continueToNextPage()); + OnboardingType onboardingType = (OnboardingType) requireArguments().getSerializable(ARG_ONBOARDING_TYPE); + dontActivateButton = view.findViewById(R.id.dont_activate_button); + if (onboardingType == OnboardingType.NON_INSTANT_PART) { + dontActivateButton.setVisibility(View.GONE); + } + dontActivateButton.setPaintFlags(dontActivateButton.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); + dontActivateButton.setOnClickListener(v -> ((OnboardingActivity) requireActivity()).continueToNextPage()); } @Override @@ -153,6 +166,7 @@ private void updateFragmentState(boolean activated) { PermissionButtonUtil.setButtonDefault(activateButton, R.string.onboarding_gaen_button_activate); } continueButton.setVisibility(activated || wasUserActive ? View.VISIBLE : View.GONE); + dontActivateButton.setVisibility(activated || wasUserActive ? View.GONE : View.VISIBLE); } } \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingSlidePageAdapter.java b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingSlidePageAdapter.java deleted file mode 100644 index d1ca78f23..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingSlidePageAdapter.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.onboarding; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import ch.admin.bag.dp3t.R; - -public class OnboardingSlidePageAdapter extends FragmentStateAdapter { - - public OnboardingSlidePageAdapter(FragmentActivity fragmentActivity) { - super(fragmentActivity); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - switch (position) { - case 0: - return OnboardingContentFragment.newInstance( - R.string.onboarding_prinzip_title, - R.string.onboarding_prinzip_heading, - R.drawable.ill_prinzip, - R.string.onboarding_prinzip_text1, - R.drawable.ic_begegnungen, - R.string.onboarding_prinzip_text2, - R.drawable.ic_message_alert, - false); - case 1: - return OnboardingContentFragment.newInstance( - R.string.onboarding_privacy_title, - R.string.onboarding_privacy_heading, - R.drawable.ill_privacy, - R.string.onboarding_privacy_text1, - R.drawable.ic_key, - R.string.onboarding_privacy_text2, - R.drawable.ic_lock, - true); - case 2: - return OnboardingContentFragment.newInstance( - R.string.onboarding_begegnungen_title, - R.string.onboarding_begegnungen_heading, - R.drawable.ill_bluetooth, - R.string.onboarding_begegnungen_text1, - R.drawable.ic_begegnungen, - R.string.onboarding_begegnungen_text2, - R.drawable.ic_bluetooth, - false); - case 3: - return OnboardingContentFragment.newInstance( - R.string.onboarding_meldung_title, - R.string.onboarding_meldung_heading, - R.drawable.ill_meldung, - R.string.onboarding_meldung_text1, - R.drawable.ic_message_alert, - R.string.onboarding_meldung_text2, - R.drawable.ic_home, - false); - case 4: - return OnboardingDisclaimerFragment.newInstance(); - case 5: - return OnboardingBatteryPermissionFragment.newInstance(); - case 6: - return OnboardingGaenPermissionFragment.newInstance(); - case 7: - return OnboardingFinishedFragment.newInstance(); - } - throw new IllegalArgumentException("There is no fragment for view pager position " + position); - } - - @Override - public int getItemCount() { - return 8; - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingSlidePageAdapter.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingSlidePageAdapter.kt new file mode 100644 index 000000000..bb5ccc1db --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingSlidePageAdapter.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.onboarding + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import ch.admin.bag.dp3t.R + +class OnboardingSlidePageAdapter(fragmentActivity: FragmentActivity, onboardingType: OnboardingType) : + FragmentStateAdapter(fragmentActivity) { + + companion object { + // Increment this number for each new Update Boarding + const val UPDATE_BOARDING_VERSION = 2 + const val CHECKIN_UPDATE_BOARDING_VERSION = 2 + } + + private val onboardingScreens = when (onboardingType) { + OnboardingType.UPDATE_BOARDING -> listOf(UpdateBoardingFragment.newInstance()) + OnboardingType.INSTANT_PART -> listOf(OnboardingDisclaimerFragment.newInstance()) + OnboardingType.NON_INSTANT_PART -> listOf( + OnboardingContentFragment.newInstance( + R.string.onboarding_begegnungen_title, + R.string.onboarding_begegnungen_heading, + R.drawable.ill_bluetooth, + intArrayOf( + R.string.onboarding_begegnungen_text1, + R.string.onboarding_begegnungen_text2, + R.string.onboarding_privacy_text1 + ), + intArrayOf(R.drawable.ic_begegnungen, R.drawable.ic_bluetooth, R.drawable.ic_key) + ), + OnboardingBatteryPermissionFragment.newInstance(), + OnboardingGaenPermissionFragment.newInstance(onboardingType), + OnboardingFinishedFragment.newInstance(onboardingType) + ) + OnboardingType.NORMAL -> listOf( + OnboardingContentFragment.newInstance( + R.string.onboarding_prinzip_title, + R.string.onboarding_prinzip_heading, + R.drawable.ill_prinzip, + intArrayOf(R.string.onboarding_prinzip_text1, R.string.onboarding_prinzip_text2), + intArrayOf(R.drawable.ic_begegnungen, R.drawable.ic_message_alert) + ), + OnboardingContentFragment.newInstance( + R.string.onboarding_checkin_title, + R.string.onboarding_checkin_heading, + R.drawable.ill_checkins, + intArrayOf(R.string.onboarding_checkin_text1, R.string.onboarding_checkin_text2), + intArrayOf(R.drawable.ic_qr, R.drawable.ic_info) + ), + OnboardingContentFragment.newInstance( + R.string.onboarding_begegnungen_title, + R.string.onboarding_begegnungen_heading, + R.drawable.ill_bluetooth, + intArrayOf( + R.string.onboarding_begegnungen_text1, + R.string.onboarding_begegnungen_text2, + R.string.onboarding_privacy_text1 + ), + intArrayOf(R.drawable.ic_begegnungen, R.drawable.ic_bluetooth, R.drawable.ic_key) + ), + OnboardingDisclaimerFragment.newInstance(), + OnboardingBatteryPermissionFragment.newInstance(), + OnboardingGaenPermissionFragment.newInstance(onboardingType), + OnboardingFinishedFragment.newInstance(onboardingType) + ) + } + + + override fun createFragment(position: Int): Fragment { + return onboardingScreens[position] + } + + override fun getItemCount(): Int { + return onboardingScreens.size + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingType.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingType.kt new file mode 100644 index 000000000..1d3aa8314 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/OnboardingType.kt @@ -0,0 +1,5 @@ +package ch.admin.bag.dp3t.onboarding + +enum class OnboardingType { + NORMAL, INSTANT_PART, NON_INSTANT_PART, UPDATE_BOARDING +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/onboarding/UpdateBoardingFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/onboarding/UpdateBoardingFragment.kt new file mode 100644 index 000000000..b4c15b3c0 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/onboarding/UpdateBoardingFragment.kt @@ -0,0 +1,66 @@ +package ch.admin.bag.dp3t.onboarding + +import android.os.Bundle +import android.text.Html +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.FragmentUpdateBoardingBinding +import ch.admin.bag.dp3t.util.AssetUtil +import ch.admin.bag.dp3t.util.UlTagHandler +import ch.admin.bag.dp3t.util.UrlUtil + +class UpdateBoardingFragment : Fragment() { + + + companion object { + fun newInstance() = UpdateBoardingFragment() + } + + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentUpdateBoardingBinding.inflate(inflater).apply { + termsOfUseTextview.text = Html.fromHtml(AssetUtil.getTermsOfUse(context), null, UlTagHandler()) + dataProtectionTextview.text = Html.fromHtml(AssetUtil.getDataProtection(context), null, UlTagHandler()) + + dataProtectionHeaderContainer.setOnClickListener { v -> + + dataProtectionContainer.isVisible = !dataProtectionContainer.isVisible + v.setBackgroundColor( + ContextCompat.getColor(v.context, if (dataProtectionContainer.isVisible) R.color.grey_light else R.color.white) + ) + + dataProtectionChevronImageview.animate() + .rotation(dataProtectionChevronImageview.rotation + 180) + .setDuration(resources.getInteger(android.R.integer.config_shortAnimTime).toLong()) + .start() + } + + + conditionsOfUseHeaderContainer.setOnClickListener { v: View -> + termsOfUseContainer.isVisible = !termsOfUseContainer.isVisible + v.setBackgroundColor( + ContextCompat.getColor(v.context, if (termsOfUseContainer.isVisible) R.color.grey_light else R.color.white) + ) + + termsOfUseChevronImageview.animate() + .rotation(termsOfUseChevronImageview.rotation + 180) + .setDuration(resources.getInteger(android.R.integer.config_shortAnimTime).toLong()) + .start() + } + dataProtectionToOnlineVersionButton.setOnClickListener { openOnlineVersion() } + termsOfUseToOnlineVersionButton.setOnClickListener { openOnlineVersion() } + updateboardingOkButton.setOnClickListener { (activity as OnboardingActivity).continueToNextPage() } + + }.root + } + + private fun openOnlineVersion() { + UrlUtil.openUrl(context, getString(R.string.onboarding_disclaimer_legal_button_url)) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsFragment.java b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsFragment.java deleted file mode 100644 index fd19ad455..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsFragment.java +++ /dev/null @@ -1,525 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.reports; - -import android.animation.ValueAnimator; -import android.app.NotificationManager; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Rect; -import android.os.Bundle; -import android.text.Spannable; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.Toolbar; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.ViewModelProvider; -import androidx.transition.AutoTransition; -import androidx.transition.Transition; -import androidx.transition.TransitionManager; - -import java.util.List; -import java.util.TimeZone; - -import org.dpppt.android.sdk.models.DayDate; -import org.dpppt.android.sdk.models.ExposureDay; - -import ch.admin.bag.dp3t.R; -import ch.admin.bag.dp3t.contacts.ReactivateTracingReminderDialog; -import ch.admin.bag.dp3t.home.model.TracingStatusInterface; -import ch.admin.bag.dp3t.storage.SecureStorage; -import ch.admin.bag.dp3t.util.*; -import ch.admin.bag.dp3t.viewmodel.TracingViewModel; -import ch.admin.bag.dp3t.whattodo.WhereToTestDialogFragment; - -public class ReportsFragment extends Fragment { - - public static ReportsFragment newInstance() { - return new ReportsFragment(); - } - - private final int DAYS_TO_STAY_IN_QUARANTINE = 10; - private final int MAX_EXPOSURE_AGE_TO_DO_A_TEST = 10; - private final int MIN_EXPOSURE_AGE_TO_DO_A_TEST = 5; - private final long ONE_DAY_IN_MILLIS = 24L * 60 * 60 * 1000; - - private TracingViewModel tracingViewModel; - private SecureStorage secureStorage; - - private View headerFragmentContainer; - private LockableScrollView scrollView; - private View scrollViewFirstchild; - - private View healthyView; - private View saveOthersView; - private View leitfadenView; - private View infectedView; - - private TextView xDaysLeftTextview; - - - private boolean leitfadenJustOpened = false; - - public ReportsFragment() { super(R.layout.fragment_reports); } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - tracingViewModel = new ViewModelProvider(requireActivity()).get(TracingViewModel.class); - secureStorage = SecureStorage.getInstance(getContext()); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - Toolbar toolbar = view.findViewById(R.id.reports_toolbar); - toolbar.setNavigationOnClickListener(v -> getParentFragmentManager().popBackStack()); - - headerFragmentContainer = view.findViewById(R.id.header_fragment_container); - scrollView = view.findViewById(R.id.reports_scrollview); - scrollViewFirstchild = view.findViewById(R.id.reports_scrollview_firstChild); - - healthyView = view.findViewById(R.id.reports_healthy); - saveOthersView = view.findViewById(R.id.reports_save_others); - leitfadenView = view.findViewById(R.id.reports_leitfaden); - infectedView = view.findViewById(R.id.reports_infected); - - xDaysLeftTextview = saveOthersView.findViewById(R.id.x_days_left_textview); - - Button openSwisscovidLeitfadenButton1 = leitfadenView.findViewById(R.id.card_encounters_button); - View leitfadenInfoButton1 = leitfadenView.findViewById(R.id.leitfaden_info_button); - Button openSwisscovidLeitfadenButton2 = saveOthersView.findViewById(R.id.card_encounters_button); - View leitfadenInfoButton2 = saveOthersView.findViewById(R.id.leitfaden_info_button); - - openSwisscovidLeitfadenButton1.setOnClickListener(view1 -> openSwissCovidLeitfaden()); - leitfadenInfoButton1.setOnClickListener(v -> showLeitfadenInfo(openSwisscovidLeitfadenButton1.getText().toString())); - openSwisscovidLeitfadenButton2.setOnClickListener(view1 -> openSwissCovidLeitfaden()); - leitfadenInfoButton2.setOnClickListener(v -> showLeitfadenInfo(openSwisscovidLeitfadenButton2.getText().toString())); - - setupFreeTestInfoBox(leitfadenView); - setupFreeTestInfoBox(saveOthersView); - - View callHotlineButton1 = leitfadenView.findViewById(R.id.item_call_hotline_layout); - View callHotlineButton2 = saveOthersView.findViewById(R.id.item_call_hotline_layout); - callHotlineButton1.setOnClickListener(v -> callHotline()); - callHotlineButton2.setOnClickListener(v -> callHotline()); - - Button faqButton1 = healthyView.findViewById(R.id.card_encounters_faq_button); - Button faqButton2 = saveOthersView.findViewById(R.id.card_encounters_faq_button); - Button faqButton3 = leitfadenView.findViewById(R.id.card_encounters_faq_button); - Button faqButton4 = infectedView.findViewById(R.id.card_encounters_faq_button); - - faqButton1.setOnClickListener(v -> showFaq()); - faqButton2.setOnClickListener(v -> showFaq()); - faqButton3.setOnClickListener(v -> showFaq()); - faqButton4.setOnClickListener(v -> showFaq()); - - View infoLinkHealthy = healthyView.findViewById(R.id.card_encounters_link); - - infoLinkHealthy.setOnClickListener(v -> openLink(R.string.no_meldungen_box_url)); - - tracingViewModel.getAppStatusLiveData().observe(getViewLifecycleOwner(), tracingStatusInterface -> { - healthyView.setVisibility(View.GONE); - saveOthersView.setVisibility(View.GONE); - leitfadenView.setVisibility(View.GONE); - infectedView.setVisibility(View.GONE); - - ReportsHeaderFragment.Type headerType; - int numExposureDays = 0; - - if (tracingStatusInterface.isReportedAsInfected()) { - headerType = ReportsHeaderFragment.Type.POSITIVE_TESTED; - infectedView.setVisibility(View.VISIBLE); - - long oldestSharedKeyDateMillis = secureStorage.getPositiveReportOldestSharedKey(); - if (oldestSharedKeyDateMillis > 0L) { - - infectedView.findViewById(R.id.card_encounters_faq_who_is_notified_container).setVisibility(View.VISIBLE); - String formattedDate = - DateUtils.getFormattedDateWrittenMonth(oldestSharedKeyDateMillis, TimeZone.getTimeZone("UTC")); - String faqText = getString(R.string.meldungen_positive_tested_faq2_text).replace("{ONSET_DATE}", - formattedDate); - Spannable formattedText = StringUtil.makePartiallyBold(faqText, formattedDate); - ((TextView) infectedView.findViewById(R.id.card_encounters_faq_who_is_notified)).setText(formattedText); - } else { - infectedView.findViewById(R.id.card_encounters_faq_who_is_notified_container).setVisibility(View.GONE); - } - - infectedView.findViewById(R.id.delete_reports).setOnClickListener(v -> { - AlertDialog.Builder builder = new AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle); - builder.setMessage(R.string.delete_infection_dialog) - .setPositiveButton(R.string.delete_infection_dialog_finish_button, (dialog, id) -> { - tracingStatusInterface.resetInfectionStatus(getContext()); - secureStorage.setIsolationEndDialogTimestamp(-1L); - secureStorage.setPositiveReportOldestSharedKey(-1L); - getParentFragmentManager().popBackStack(); - }) - .setNegativeButton(R.string.cancel, (dialog, id) -> { - //do nothing - }); - builder.create(); - builder.show(); - }); - if (!tracingStatusInterface.canInfectedStatusBeReset(getContext())) { - infectedView.findViewById(R.id.delete_reports).setVisibility(View.GONE); - } - } else if (tracingStatusInterface.wasContactReportedAsExposed()) { - headerType = ReportsHeaderFragment.Type.POSSIBLE_INFECTION; - numExposureDays = tracingStatusInterface.getExposureDays().size(); - boolean isOpenLeitfadenPending = secureStorage.isOpenLeitfadenPending(); - if (isOpenLeitfadenPending) { - leitfadenView.setVisibility(View.VISIBLE); - } else { - saveOthersView.setVisibility(View.VISIBLE); - } - int daysLeft = DAYS_TO_STAY_IN_QUARANTINE - (int) tracingStatusInterface.getDaysSinceExposure(); - if (daysLeft > DAYS_TO_STAY_IN_QUARANTINE || daysLeft <= 0) { - xDaysLeftTextview.setVisibility(View.GONE); - } else if (daysLeft == 1) { - xDaysLeftTextview.setText(R.string.date_in_one_day); - } else { - xDaysLeftTextview.setText(getString(R.string.date_in_days).replace("{COUNT}", String.valueOf(daysLeft))); - } - leitfadenView.findViewById(R.id.delete_reports) - .setOnClickListener(v -> deleteNotifications(tracingStatusInterface)); - saveOthersView.findViewById(R.id.delete_reports) - .setOnClickListener(v -> deleteNotifications(tracingStatusInterface)); - } else { - healthyView.setVisibility(View.VISIBLE); - headerType = ReportsHeaderFragment.Type.NO_REPORTS; - } - - setupHeaderFragment(headerType, numExposureDays); - }); - - NotificationManager notificationManager = - (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel(NotificationUtil.NOTIFICATION_ID_CONTACT); - } - - - private void deleteNotifications(TracingStatusInterface tracingStatusInterface) { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity(), R.style.NextStep_AlertDialogStyle); - builder.setMessage(R.string.delete_notification_dialog) - .setPositiveButton(R.string.delete_reports_button, (dialog, id) -> { - tracingStatusInterface.resetExposureDays(getContext()); - getParentFragmentManager().popBackStack(); - }) - .setNegativeButton(R.string.cancel, (dialog, id) -> { - //do nothing - }); - builder.create(); - builder.show(); - } - - private void setupFreeTestInfoBox(@NonNull View view) { - - View testLocationsButton = view.findViewById(R.id.testlocations_link); - testLocationsButton.setOnClickListener(v -> showWhereToTestDialog()); - - TextView testCountdownTextView = view.findViewById(R.id.test_countdown_textview); - - List exposureDays = tracingViewModel.getAppStatusLiveData().getValue().getExposureDays(); - DayDate today = new DayDate(); - DayDate oldestExposure = null; // This includes only exposures that are newer than MIN_EXPOSURE_AGE_TO_DO_A_TEST - - for (ExposureDay exposureDay : exposureDays) { - if (exposureDay.getExposedDate().addDays(MIN_EXPOSURE_AGE_TO_DO_A_TEST).isBeforeOrEquals(today) && - !exposureDay.getExposedDate().addDays(MAX_EXPOSURE_AGE_TO_DO_A_TEST).isBefore(today)) { - testCountdownTextView.setText(R.string.meldungen_detail_free_test_now); - return; - } - if (!exposureDay.getExposedDate().addDays(MIN_EXPOSURE_AGE_TO_DO_A_TEST).isBeforeOrEquals(today)) { - if (oldestExposure == null || exposureDay.getExposedDate().isBefore(oldestExposure)) { - oldestExposure = exposureDay.getExposedDate(); - } - } - } - - if (oldestExposure != null) { - int daysSinceFirstExposure = - (int) ((today.getStartOfDayTimestamp() - oldestExposure.getStartOfDayTimestamp()) / ONE_DAY_IN_MILLIS); - int daysUntilTest = MIN_EXPOSURE_AGE_TO_DO_A_TEST - daysSinceFirstExposure; - if (daysUntilTest == 1) { - testCountdownTextView.setText(R.string.meldungen_detail_free_test_tomorrow); - } else { - testCountdownTextView.setText(getString(R.string.meldungen_detail_free_test_in_x_tagen) - .replace("{COUNT}", String.valueOf(daysUntilTest))); - } - } else { - testCountdownTextView.setVisibility(View.GONE); - } - } - - private void showWhereToTestDialog() { - requireActivity().getSupportFragmentManager().beginTransaction() - .add(WhereToTestDialogFragment.newInstance(), WhereToTestDialogFragment.class.getCanonicalName()) - .commit(); - } - - private void openLink(@StringRes int stringRes) { - UrlUtil.openUrl(getContext(), getString(stringRes)); - } - - private void showFaq() { - UrlUtil.openUrl(getContext(), getString(R.string.faq_button_url)); - } - - private void openSwissCovidLeitfaden() { - leitfadenJustOpened = true; - secureStorage.leitfadenOpened(); - List exposureDays = tracingViewModel.getAppStatusLiveData().getValue().getExposureDays(); - StringBuilder contactDates = new StringBuilder(); - String delimiter = ""; - for (ExposureDay exposureDay : exposureDays) { - contactDates.append(delimiter); - contactDates.append(exposureDay.getExposedDate().formatAsString()); - delimiter = ","; - } - UrlUtil.openUrl(getContext(), getString(R.string.swisscovid_leitfaden_url).replace("{CONTACT_DATES}", contactDates)); - } - - private void showLeitfadenInfo(String buttonTitleReplacementText) { - String title = getString(R.string.leitfaden_infopopup_title); - String subtitle = getString(R.string.leitfaden_infopopup_text).replace("{BUTTON_TITLE}", buttonTitleReplacementText); - requireActivity().getSupportFragmentManager().beginTransaction().add( - SimpleDismissableDialog.newInstance(title, subtitle), - ReactivateTracingReminderDialog.class.getCanonicalName()).commit(); - } - - private void callHotline() { - PhoneUtil.callHelpline(getContext()); - } - - @Override - public void onResume() { - super.onResume(); - - if (leitfadenJustOpened) { - leitfadenJustOpened = false; - leitfadenView.setVisibility(View.GONE); - saveOthersView.setVisibility(View.VISIBLE); - } - } - - - public void doHeaderAnimation(View info, View image, Button button, View showAllButton, int numExposureDays) { - secureStorage.setReportsHeaderAnimationPending(false); - - ViewGroup rootView = (ViewGroup) getView(); - - scrollViewFirstchild.setPadding(scrollViewFirstchild.getPaddingLeft(), - rootView.getHeight(), - scrollViewFirstchild.getPaddingRight(), scrollViewFirstchild.getPaddingBottom()); - scrollViewFirstchild.setVisibility(View.VISIBLE); - - rootView.post(() -> { - - AutoTransition autoTransition = new AutoTransition(); - autoTransition.setDuration(300); - autoTransition.addListener(new Transition.TransitionListener() { - @Override - public void onTransitionStart(@NonNull Transition transition) { - - } - - @Override - public void onTransitionEnd(@NonNull Transition transition) { - headerFragmentContainer.post(() -> setupScrollBehavior()); - } - - @Override - public void onTransitionCancel(@NonNull Transition transition) { - - } - - @Override - public void onTransitionPause(@NonNull Transition transition) { - - } - - @Override - public void onTransitionResume(@NonNull Transition transition) { - - } - }); - - TransitionManager.beginDelayedTransition(rootView, autoTransition); - - updateHeaderSize(false, numExposureDays); - - info.setVisibility(View.VISIBLE); - image.setVisibility(View.GONE); - button.setVisibility(View.GONE); - if (numExposureDays <= 1) { - showAllButton.setVisibility(View.GONE); - } else { - showAllButton.setVisibility(View.VISIBLE); - } - }); - } - - public void animateHeaderHeight(boolean showAll, int numExposureDays, View exposureDaysContainer, View dateTextView) { - - int exposureDayItemHeight = getResources().getDimensionPixelSize(R.dimen.header_reports_exposure_day_height); - int endExposureDayTopPadding; - int endHeaderHeight; - int endDateTextHeight; - int endExposureDaysContainerHeight; - int endScrollViewPadding; - if (showAll) { - endExposureDayTopPadding = getResources().getDimensionPixelSize(R.dimen.spacing_medium); - endHeaderHeight = Math.min(getScreenHeight() / 3 * 2, - getResources().getDimensionPixelSize(R.dimen.header_height_reports_multiple_days) + - exposureDayItemHeight * (numExposureDays - 1) + endExposureDayTopPadding); - endDateTextHeight = 0; - endExposureDaysContainerHeight = - endHeaderHeight - getResources().getDimensionPixelSize(R.dimen.header_height_reports_multiple_days) + - exposureDayItemHeight; - endScrollViewPadding = - endHeaderHeight - getResources().getDimensionPixelSize(R.dimen.top_item_header_overlap_reports_multiple_days); - } else { - endExposureDayTopPadding = 0; - endHeaderHeight = getResources().getDimensionPixelSize(R.dimen.header_height_reports_multiple_days); - endDateTextHeight = exposureDayItemHeight; - endExposureDaysContainerHeight = 0; - endScrollViewPadding = getResources().getDimensionPixelSize(R.dimen.top_item_padding_reports_multiple_days); - } - - int startExposureDayTopPadding = exposureDaysContainer.getPaddingTop(); - int startHeaderHeight = headerFragmentContainer.getLayoutParams().height; - int startScrollViewPadding = scrollViewFirstchild.getPaddingTop(); - int startDateTextHeight = dateTextView.getLayoutParams().height; - int startExposureDaysContainerHeight = exposureDaysContainer.getLayoutParams().height; - - ValueAnimator anim = ValueAnimator.ofFloat(0, 1); - anim.addUpdateListener(v -> { - float value = (float) v.getAnimatedValue(); - setHeight(headerFragmentContainer, value * (endHeaderHeight - startHeaderHeight) + startHeaderHeight); - setHeight(dateTextView, value * (endDateTextHeight - startDateTextHeight) + startDateTextHeight); - setHeight(exposureDaysContainer, - value * (endExposureDaysContainerHeight - startExposureDaysContainerHeight) + startExposureDaysContainerHeight); - scrollViewFirstchild.setPadding(scrollViewFirstchild.getPaddingLeft(), - (int) (value * (endScrollViewPadding - startScrollViewPadding) + startScrollViewPadding), - scrollViewFirstchild.getPaddingRight(), scrollViewFirstchild.getPaddingBottom()); - exposureDaysContainer.setPadding(exposureDaysContainer.getPaddingLeft(), - (int) (value * (endExposureDayTopPadding - startExposureDayTopPadding) + startExposureDayTopPadding), - exposureDaysContainer.getPaddingRight(), exposureDaysContainer.getPaddingBottom()); - if (value == 0) { - exposureDaysContainer.setVisibility(View.VISIBLE); - dateTextView.setVisibility(View.VISIBLE); - } else if (value == 1) { - if (showAll) { - dateTextView.setVisibility(View.GONE); - } else { - exposureDaysContainer.setVisibility(View.GONE); - } - headerFragmentContainer.post(this::setupScrollBehavior); - } - } - ); - anim.setDuration(100); - anim.start(); - } - - private int getScreenHeight() { - return Resources.getSystem().getDisplayMetrics().heightPixels; - } - - private void setHeight(View view, float height) { - ViewGroup.LayoutParams params = view.getLayoutParams(); - params.height = (int) height; - view.setLayoutParams(params); - } - - private void updateHeaderSize(boolean isReportsHeaderAnimationPending, int numExposureDays) { - ViewGroup.LayoutParams headerLp = headerFragmentContainer.getLayoutParams(); - if (isReportsHeaderAnimationPending) { - headerLp.height = ViewGroup.LayoutParams.MATCH_PARENT; - } else if (numExposureDays <= 1) { - headerLp.height = getResources().getDimensionPixelSize(R.dimen.header_height_reports); - scrollViewFirstchild.setPadding(scrollViewFirstchild.getPaddingLeft(), - getResources().getDimensionPixelSize(R.dimen.top_item_padding_reports), - scrollViewFirstchild.getPaddingRight(), scrollViewFirstchild.getPaddingBottom()); - } else { - headerLp.height = getResources().getDimensionPixelSize(R.dimen.header_height_reports_multiple_days); - scrollViewFirstchild.setPadding(scrollViewFirstchild.getPaddingLeft(), - getResources().getDimensionPixelSize(R.dimen.top_item_padding_reports_multiple_days), - scrollViewFirstchild.getPaddingRight(), scrollViewFirstchild.getPaddingBottom()); - } - headerFragmentContainer.setLayoutParams(headerLp); - headerFragmentContainer.post(this::setupScrollBehavior); - } - - private void setupScrollBehavior() { - if (!isVisible()) return; - - Rect rect = new Rect(); - headerFragmentContainer.getDrawingRect(rect); - scrollView.setScrollPreventRect(rect); - - int scrollRangePx = scrollViewFirstchild.getPaddingTop(); - int translationRangePx = -getResources().getDimensionPixelSize(R.dimen.spacing_huge); - scrollView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> { - float progress = computeScrollAnimProgress(scrollY, scrollRangePx); - headerFragmentContainer.setAlpha(1 - progress); - headerFragmentContainer.setTranslationY(progress * translationRangePx); - }); - scrollView.post(() -> { - float progress = computeScrollAnimProgress(scrollView.getScrollY(), scrollRangePx); - headerFragmentContainer.setAlpha(1 - progress); - headerFragmentContainer.setTranslationY(progress * translationRangePx); - }); - } - - private float computeScrollAnimProgress(int scrollY, int scrollRange) { - return Math.min(scrollY, scrollRange) / (float) scrollRange; - } - - private void setupHeaderFragment(ReportsHeaderFragment.Type headerType, int numExposureDays) { - - boolean isReportsHeaderAnimationPending = secureStorage.isReportsHeaderAnimationPending(); - - updateHeaderSize(isReportsHeaderAnimationPending, numExposureDays); - - if (isReportsHeaderAnimationPending) { - scrollViewFirstchild.setVisibility(View.GONE); - } - - headerFragmentContainer.post(this::setupScrollBehavior); - - Fragment header; - switch (headerType) { - case NO_REPORTS: - header = ReportsHeaderFragment.newInstance(ReportsHeaderFragment.Type.NO_REPORTS, false); - break; - case POSSIBLE_INFECTION: - header = ReportsHeaderFragment - .newInstance(ReportsHeaderFragment.Type.POSSIBLE_INFECTION, isReportsHeaderAnimationPending); - break; - case POSITIVE_TESTED: - header = ReportsHeaderFragment.newInstance(ReportsHeaderFragment.Type.POSITIVE_TESTED, false); - break; - default: - throw new IllegalStateException("Unexpected value: " + headerType); - } - - getChildFragmentManager().beginTransaction() - .replace(R.id.header_fragment_container, header) - .commit(); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsFragment.kt new file mode 100644 index 000000000..0b4c8c0bf --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsFragment.kt @@ -0,0 +1,495 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.reports + +import android.animation.ValueAnimator +import android.app.NotificationManager +import android.content.Context +import android.content.res.Resources +import android.graphics.Paint +import android.graphics.Rect +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.LayoutRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.transition.AutoTransition +import androidx.transition.Transition +import androidx.transition.TransitionManager +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.contacts.ReactivateTracingReminderDialog +import ch.admin.bag.dp3t.databinding.FragmentReportsBinding +import ch.admin.bag.dp3t.databinding.FragmentReportsHeaderPossibleInfectionBinding +import ch.admin.bag.dp3t.extensions.getDetailsString +import ch.admin.bag.dp3t.home.model.TracingStatusInterface +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.util.* +import ch.admin.bag.dp3t.viewmodel.TracingViewModel +import java.util.* +import kotlin.math.min + +private const val ARG_CHECK_IN_ID = "ARG_CHECK_IN_ID" +private const val ARG_SHOW_TRACING_REPORT_DETAILS = "ARG_SHOW_TRACING_REPORT_DETAILS" + +class ReportsFragment : Fragment() { + + companion object { + + @JvmStatic + fun newInstance(reportItem: ReportItem? = null): ReportsFragment { + return ReportsFragment().apply { + when (reportItem) { + is ProximityTracingReportItem -> arguments = bundleOf(ARG_SHOW_TRACING_REPORT_DETAILS to true) + is CheckinReportItem -> arguments = bundleOf(ARG_CHECK_IN_ID to reportItem.exposure.id) + } + } + } + } + + private val crowdNotifierViewModel: CrowdNotifierViewModel by activityViewModels() + private val tracingViewModel: TracingViewModel by activityViewModels() + private val secureStorage by lazy { SecureStorage.getInstance(requireContext()) } + private val diaryStorage by lazy { DiaryStorage.getInstance(requireContext()) } + + + private lateinit var binding: FragmentReportsBinding + + private var leitfadenJustOpened = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentReportsBinding.inflate(inflater) + return binding.apply { + reportsToolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + val showTracingReports = arguments?.getBoolean(ARG_SHOW_TRACING_REPORT_DETAILS, false) == true + val showCheckinReport = arguments?.getLong(ARG_CHECK_IN_ID) != null + + crowdNotifierViewModel.exposures.observe(viewLifecycleOwner) { checkinExposures -> + val tracingStatusInterface = tracingViewModel.appStatusLiveData.value!! + setupState( + when { + showTracingReports -> State.POSSIBLE_INFECTION_TRACING_REPORTS + showCheckinReport -> State.POSSIBLE_INFECTION_CHECKIN_REPORT + tracingStatusInterface.isReportedAsInfected -> State.POSITIVE_TESTED + tracingStatusInterface.wasContactReportedAsExposed() && checkinExposures.isEmpty() -> State.POSSIBLE_INFECTION_TRACING_REPORTS + !tracingStatusInterface.wasContactReportedAsExposed() && checkinExposures.isNotEmpty() -> State.POSSIBLE_INFECTION_CHECKIN_REPORT + else -> State.NO_REPORTS + }, tracingStatusInterface + ) + + } + }.root + } + + private fun setupHeader(state: State) { + binding.apply { + headerFragmentContainer.removeAllViews() + val isReportsHeaderAnimationPending = + secureStorage.isReportsHeaderAnimationPending && (state == State.POSSIBLE_INFECTION_TRACING_REPORTS || state == State.POSSIBLE_INFECTION_CHECKIN_REPORT) + val exposureDays = tracingViewModel.appStatusLiveData.value?.exposureDays ?: listOf() + updateHeaderSize(isReportsHeaderAnimationPending, state, exposureDays.size) + if (isReportsHeaderAnimationPending) { + reportsScrollviewFirstChild.visibility = View.GONE + } + headerFragmentContainer.post { setupScrollBehavior() } + + val headerView = + LayoutInflater.from(requireContext()).inflate(state.headerLayoutResource, headerFragmentContainer, true) + if (state == State.NO_REPORTS || state == State.POSITIVE_TESTED) return + + FragmentReportsHeaderPossibleInfectionBinding.bind(headerView).apply { + + if (state == State.POSSIBLE_INFECTION_CHECKIN_REPORT) { + headerShowAllButton.isVisible = false + headerDate.isVisible = false + headerSlogan.isVisible = true + datesContainer.isVisible = false + headerSubtitle.isVisible = false + } else { + if (exposureDays.isNotEmpty()) { + headerDate.text = + StringUtil.getReportDateString( + exposureDays[exposureDays.size - 1].exposedDate.getStartOfDay(TimeZone.getDefault()), + withDiff = true, + withPrefix = true, + requireContext() + ) + } + datesContainer.removeAllViews() + for (exposureDay in exposureDays) { + val itemView = LayoutInflater.from(context) + .inflate(R.layout.item_reports_exposure_day, datesContainer, false) + val itemDate = itemView.findViewById(R.id.exposure_day_textview) + itemDate.text = StringUtil.getReportDateString( + exposureDay.exposedDate.getStartOfDay(TimeZone.getDefault()), + withDiff = false, + withPrefix = true, + requireContext() + ) + datesContainer.addView(itemView, 0) + } + + headerShowAllButton.isVisible = exposureDays.size > 1 + + headerShowAllButton.setOnClickListener { + if (datesScrollView.visibility == View.VISIBLE) { + headerSubtitle.setText(R.string.meldung_detail_exposed_subtitle_last_encounter) + headerShowAllButton.setText(R.string.meldung_detail_exposed_show_all_button) + animateHeaderHeight(false, exposureDays.size, datesScrollView, headerDate) + } else { + headerSubtitle.setText(R.string.meldung_detail_exposed_subtitle_all_encounters) + headerShowAllButton.setText(R.string.meldung_detail_exposed_show_less_button) + animateHeaderHeight(true, exposureDays.size, datesScrollView, headerDate) + } + } + headerShowAllButton.paintFlags = headerShowAllButton.paintFlags or Paint.UNDERLINE_TEXT_FLAG + } + + + if (isReportsHeaderAnimationPending) { + headerInfo.isVisible = false + headerShowAllButton.isVisible = false + headerImage.isVisible = true + headerSubtitle.isVisible = false + headerSlogan.isVisible = true + headerContinueButton.isVisible = true + headerDate.isVisible = false + headerTitle.setText(R.string.meldung_detail_exposed_title) + headerContinueButton.setOnClickListener { doHeaderCollapseAnimation(this, exposureDays.size, state) } + } + } + } + } + + private fun setupState(state: State, tracingStatusInterface: TracingStatusInterface) { + + binding.apply { + reportsHealthy.root.isVisible = state == State.NO_REPORTS + reportsInfected.root.isVisible = state == State.POSITIVE_TESTED + reportsLeitfaden.root.isVisible = state == State.POSSIBLE_INFECTION_TRACING_REPORTS + reportsCheckinReport.root.isVisible = state == State.POSSIBLE_INFECTION_CHECKIN_REPORT + } + when (state) { + State.NO_REPORTS -> { + binding.reportsHealthy.apply { + cardEncountersLink.setOnClickListener { openLink(R.string.no_meldungen_box_url) } + faqButton.setOnClickListener { showFaq() } + } + } + State.POSSIBLE_INFECTION_TRACING_REPORTS -> { + binding.reportsLeitfaden.apply { + reportFurtherInformation.itemCallHotlineLayout.setOnClickListener { callHotline() } + reportFurtherInformation.testsExternalLink.setOnClickListener { showTestInformation() } + faqButton.setOnClickListener { showFaq() } + val isOpenLeitfadenPending = secureStorage.isOpenLeitfadenPending + fillLeitfadenNowButton.isVisible = isOpenLeitfadenPending + zumLeitfadenButton.isVisible = !isOpenLeitfadenPending + if (isOpenLeitfadenPending) { + fillLeitfadenNowButton.setOnClickListener { openSwissCovidLeitfaden() } + leitfadenInfoButton.setOnClickListener { showLeitfadenInfo(fillLeitfadenNowButton.text.toString()) } + } else { + zumLeitfadenButton.setOnClickListener { openSwissCovidLeitfaden() } + leitfadenInfoButton.setOnClickListener { showLeitfadenInfo(zumLeitfadenButton.text.toString()) } + } + deleteReports.setOnClickListener { deleteNotifications(tracingStatusInterface) } + } + } + State.POSITIVE_TESTED -> { + val oldestSharedKeyDateMillis = secureStorage.positiveReportOldestSharedKeyOrCheckin + binding.reportsInfected.apply { + faqButton.setOnClickListener { showFaq() } + cardEncountersFaqWhoIsNotifiedContainer.isVisible = oldestSharedKeyDateMillis > 0L + val formattedDate = + DateUtils.getFormattedDateWrittenMonth(oldestSharedKeyDateMillis, TimeZone.getTimeZone("UTC")) + val faqText = getString(R.string.meldungen_positive_tested_faq2_text).replace("{ONSET_DATE}", formattedDate) + val formattedText = StringUtil.makePartiallyBold(faqText, formattedDate) + cardEncountersFaqWhoIsNotified.text = formattedText + deleteReports.setOnClickListener { showEndIsolationConfirmationDialog() } + deleteReports.isVisible = tracingStatusInterface.canInfectedStatusBeReset(requireContext()) + } + } + State.POSSIBLE_INFECTION_CHECKIN_REPORT -> { + binding.reportsCheckinReport.apply { + (crowdNotifierViewModel.getExposureWithId(arguments?.getLong(ARG_CHECK_IN_ID) ?: -1) + ?: crowdNotifierViewModel.latestExposure)?.let { exposureEvent -> + deleteReport.setOnClickListener { deleteNotifications(tracingStatusInterface, exposureEvent.id) } + reportDetails.text = exposureEvent.getDetailsString(requireContext()) + + val diaryEntry: DiaryEntry? = diaryStorage.getDiaryEntryWithId(exposureEvent.id) + place.isVisible = diaryEntry != null + place.text = diaryEntry?.venueInfo?.title + + } + faqButton.setOnClickListener { showFaq() } + reportFurtherInformation.testsExternalLink.setOnClickListener { showTestInformation() } + reportFurtherInformation.phoneSection.isVisible = false + } + } + } + val notificationManager = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NotificationUtil.NOTIFICATION_ID_CONTACT) + setupHeader(state) + } + + private fun showEndIsolationConfirmationDialog() { + AlertDialog.Builder(requireContext(), R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.delete_infection_dialog) + .setPositiveButton(R.string.delete_infection_dialog_finish_button) { _, _ -> + TracingStatusHelper.resetStateAfterIsolation(activity, tracingViewModel) + parentFragmentManager.popBackStack() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .create() + .show() + } + + private fun deleteNotifications(tracingStatusInterface: TracingStatusInterface, checkinId: Long? = null) { + AlertDialog.Builder(requireActivity(), R.style.NextStep_AlertDialogStyle) + .setMessage(R.string.delete_notification_dialog) + .setPositiveButton(R.string.delete_reports_button) { _, _ -> + if (checkinId == null) { + tracingStatusInterface.resetExposureDays(context) + } else { + crowdNotifierViewModel.removeExposure(checkinId) + } + parentFragmentManager.popBackStack() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } + .create() + .show() + } + + private fun openLink(@StringRes stringRes: Int) { + UrlUtil.openUrl(context, getString(stringRes)) + } + + private fun showFaq() { + UrlUtil.openUrl(context, getString(R.string.faq_button_url)) + } + + private fun openSwissCovidLeitfaden() { + leitfadenJustOpened = true + secureStorage.leitfadenOpened() + val contactDates = StringBuilder() + var delimiter = "" + tracingViewModel.appStatusLiveData.value?.exposureDays?.forEach { + contactDates.append(delimiter) + contactDates.append(it.exposedDate.formatAsString()) + delimiter = "," + } + UrlUtil.openUrl(context, getString(R.string.swisscovid_leitfaden_url).replace("{CONTACT_DATES}", contactDates.toString())) + } + + private fun showLeitfadenInfo(buttonTitleReplacementText: String) { + val title = getString(R.string.leitfaden_infopopup_title) + val subtitle = getString(R.string.leitfaden_infopopup_text).replace("{BUTTON_TITLE}", buttonTitleReplacementText) + requireActivity().supportFragmentManager.beginTransaction().add( + SimpleDismissableDialog.newInstance(title, subtitle), + ReactivateTracingReminderDialog::class.java.canonicalName + ).commit() + } + + private fun showTestInformation() { + UrlUtil.openUrl(context, secureStorage.getTestInformationUrl(getString(R.string.language_key))) + } + + private fun callHotline() { + PhoneUtil.callHelpline(context) + } + + override fun onResume() { + super.onResume() + if (leitfadenJustOpened) { + leitfadenJustOpened = false + setupState(State.POSSIBLE_INFECTION_TRACING_REPORTS, tracingViewModel.tracingStatusInterface) + } + } + + private fun doHeaderCollapseAnimation( + headerBinding: FragmentReportsHeaderPossibleInfectionBinding, + numExposureDays: Int, + state: State + ) { + secureStorage.isReportsHeaderAnimationPending = false + binding.apply { + reportsScrollviewFirstChild.updatePadding(top = root.height) + reportsScrollviewFirstChild.isVisible = true + root.post { + val autoTransition = AutoTransition() + autoTransition.duration = 300 + autoTransition.addListener(object : Transition.TransitionListener { + override fun onTransitionStart(transition: Transition) {} + override fun onTransitionEnd(transition: Transition) { + headerFragmentContainer.post { setupScrollBehavior() } + } + + override fun onTransitionCancel(transition: Transition) {} + override fun onTransitionPause(transition: Transition) {} + override fun onTransitionResume(transition: Transition) {} + }) + TransitionManager.beginDelayedTransition(root, autoTransition) + updateHeaderSize(false, state, numExposureDays) + headerBinding.apply { + headerInfo.isVisible = true + headerImage.isVisible = false + headerContinueButton.isVisible = false + headerShowAllButton.isVisible = state == State.POSSIBLE_INFECTION_TRACING_REPORTS && numExposureDays > 1 + headerDate.isVisible = state == State.POSSIBLE_INFECTION_TRACING_REPORTS + headerSlogan.isVisible = state == State.POSSIBLE_INFECTION_CHECKIN_REPORT + headerSubtitle.isVisible = state == State.POSSIBLE_INFECTION_TRACING_REPORTS + } + } + } + } + + private fun animateHeaderHeight(showAll: Boolean, numExposureDays: Int, exposureDaysContainer: View, dateTextView: View) { + val exposureDayItemHeight = resources.getDimensionPixelSize(R.dimen.header_reports_exposure_day_height) + val endExposureDayTopPadding: Int + val endHeaderHeight: Int + val endDateTextHeight: Int + val endExposureDaysContainerHeight: Int + val endScrollViewPadding: Int + val screenHeight = Resources.getSystem().displayMetrics.heightPixels + if (showAll) { + endExposureDayTopPadding = resources.getDimensionPixelSize(R.dimen.spacing_medium) + endHeaderHeight = min( + screenHeight / 3 * 2, + resources.getDimensionPixelSize(R.dimen.header_height_reports_multiple_days) + exposureDayItemHeight * (numExposureDays - 1) + endExposureDayTopPadding + ) + endDateTextHeight = 0 + endExposureDaysContainerHeight = + endHeaderHeight - resources.getDimensionPixelSize(R.dimen.header_height_reports_multiple_days) + exposureDayItemHeight + endScrollViewPadding = + endHeaderHeight - resources.getDimensionPixelSize(R.dimen.top_item_header_overlap_reports_multiple_days) + } else { + endExposureDayTopPadding = 0 + endHeaderHeight = resources.getDimensionPixelSize(R.dimen.header_height_reports_multiple_days) + endDateTextHeight = exposureDayItemHeight + endExposureDaysContainerHeight = 0 + endScrollViewPadding = resources.getDimensionPixelSize(R.dimen.top_item_padding_reports_multiple_days) + } + val startExposureDayTopPadding = exposureDaysContainer.paddingTop + + binding.apply { + val startHeaderHeight = headerFragmentContainer.layoutParams.height + val startScrollViewPadding = reportsScrollviewFirstChild.paddingTop + val startDateTextHeight = dateTextView.layoutParams.height + val startExposureDaysContainerHeight = exposureDaysContainer.layoutParams.height + val anim = ValueAnimator.ofFloat(0f, 1f) + anim.addUpdateListener { v: ValueAnimator -> + val value = v.animatedValue as Float + setHeight(headerFragmentContainer, value * (endHeaderHeight - startHeaderHeight) + startHeaderHeight) + setHeight(dateTextView, value * (endDateTextHeight - startDateTextHeight) + startDateTextHeight) + setHeight( + exposureDaysContainer, + value * (endExposureDaysContainerHeight - startExposureDaysContainerHeight) + startExposureDaysContainerHeight + ) + reportsScrollviewFirstChild.setPadding( + reportsScrollviewFirstChild.paddingLeft, + (value * (endScrollViewPadding - startScrollViewPadding) + startScrollViewPadding).toInt(), + reportsScrollviewFirstChild.paddingRight, reportsScrollviewFirstChild.paddingBottom + ) + exposureDaysContainer.setPadding( + exposureDaysContainer.paddingLeft, + (value * (endExposureDayTopPadding - startExposureDayTopPadding) + startExposureDayTopPadding).toInt(), + exposureDaysContainer.paddingRight, exposureDaysContainer.paddingBottom + ) + if (value == 0f) { + exposureDaysContainer.visibility = View.VISIBLE + dateTextView.visibility = View.VISIBLE + } else if (value == 1f) { + if (showAll) { + dateTextView.visibility = View.GONE + } else { + exposureDaysContainer.visibility = View.GONE + } + headerFragmentContainer.post { setupScrollBehavior() } + } + } + anim.duration = 100 + anim.start() + } + } + + private fun setHeight(view: View, newHeight: Float) { + view.updateLayoutParams { + height = newHeight.toInt() + } + } + + private fun updateHeaderSize(isReportsHeaderAnimationPending: Boolean, state: State, numExposureDays: Int = 0) { + binding.apply { + val headerLp = headerFragmentContainer.layoutParams + if (isReportsHeaderAnimationPending) { + headerLp.height = ViewGroup.LayoutParams.MATCH_PARENT + } else if (numExposureDays <= 1 || state != State.POSSIBLE_INFECTION_TRACING_REPORTS) { + headerLp.height = resources.getDimensionPixelSize(R.dimen.header_height_reports) + reportsScrollviewFirstChild.setPadding( + reportsScrollviewFirstChild.paddingLeft, + resources.getDimensionPixelSize(R.dimen.top_item_padding_reports), + reportsScrollviewFirstChild.paddingRight, reportsScrollviewFirstChild.paddingBottom + ) + } else { + headerLp.height = resources.getDimensionPixelSize(R.dimen.header_height_reports_multiple_days) + reportsScrollviewFirstChild.setPadding( + reportsScrollviewFirstChild.paddingLeft, + resources.getDimensionPixelSize(R.dimen.top_item_padding_reports_multiple_days), + reportsScrollviewFirstChild.paddingRight, reportsScrollviewFirstChild.paddingBottom + ) + } + headerFragmentContainer.layoutParams = headerLp + headerFragmentContainer.post { setupScrollBehavior() } + } + } + + private fun setupScrollBehavior() { + if (!isVisible) return + val rect = Rect() + binding.apply { + headerFragmentContainer.getDrawingRect(rect) + reportsScrollview.setScrollPreventRect(rect) + val scrollRangePx = reportsScrollviewFirstChild.paddingTop + val translationRangePx = -resources.getDimensionPixelSize(R.dimen.spacing_huge) + reportsScrollview.setOnScrollChangeListener { _, _, scrollY, _, _ -> + val progress = computeScrollAnimProgress(scrollY, scrollRangePx) + headerFragmentContainer.alpha = 1 - progress + headerFragmentContainer.translationY = progress * translationRangePx + } + reportsScrollview.post { + val progress = computeScrollAnimProgress(reportsScrollview.scrollY, scrollRangePx) + headerFragmentContainer.alpha = 1 - progress + headerFragmentContainer.translationY = progress * translationRangePx + } + } + } + + private fun computeScrollAnimProgress(scrollY: Int, scrollRange: Int): Float { + return min(scrollY, scrollRange) / scrollRange.toFloat() + } + + enum class State(@LayoutRes val headerLayoutResource: Int) { + NO_REPORTS(R.layout.fragment_reports_header_no_reports), + POSSIBLE_INFECTION_CHECKIN_REPORT(R.layout.fragment_reports_header_possible_infection), + POSSIBLE_INFECTION_TRACING_REPORTS(R.layout.fragment_reports_header_possible_infection), + POSITIVE_TESTED(R.layout.fragment_reports_header_positive_tested) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsHeaderFragment.java b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsHeaderFragment.java deleted file mode 100644 index b6eb4276d..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsHeaderFragment.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ -package ch.admin.bag.dp3t.reports; - -import android.graphics.Paint; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.ViewModelProvider; - -import java.util.List; -import java.util.TimeZone; - -import org.dpppt.android.sdk.models.ExposureDay; - -import ch.admin.bag.dp3t.R; -import ch.admin.bag.dp3t.util.DateUtils; -import ch.admin.bag.dp3t.viewmodel.TracingViewModel; - -public class ReportsHeaderFragment extends Fragment { - - private static final String ARG_TYPE = "ARG_TYPE"; - private static final String ARG_SHOWANIMATIONCONTROLS = "ARG_SHOWANIMATIONCONTROLS"; - - public static ReportsHeaderFragment newInstance(@NonNull Type type, boolean showAnimationControls) { - ReportsHeaderFragment reportsHeaderFragment = new ReportsHeaderFragment(); - Bundle args = new Bundle(); - args.putInt(ARG_TYPE, type.ordinal()); - args.putBoolean(ARG_SHOWANIMATIONCONTROLS, showAnimationControls); - reportsHeaderFragment.setArguments(args); - return reportsHeaderFragment; - } - - public enum Type { - NO_REPORTS, - POSSIBLE_INFECTION, - POSITIVE_TESTED - } - - - private TracingViewModel tracingViewModel; - private Type type; - private boolean showAnimationControls; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - tracingViewModel = new ViewModelProvider(requireActivity()).get(TracingViewModel.class); - type = Type.values()[getArguments().getInt(ARG_TYPE)]; - showAnimationControls = getArguments().getBoolean(ARG_SHOWANIMATIONCONTROLS); - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - - View view = null; - - switch (type) { - case NO_REPORTS: - view = inflater.inflate(R.layout.fragment_reports_header_no_reports, container, false); - break; - case POSSIBLE_INFECTION: - view = inflater.inflate(R.layout.fragment_reports_header_possible_infection, container, false); - break; - case POSITIVE_TESTED: - view = inflater.inflate(R.layout.fragment_reports_header_positive_tested, container, false); - break; - } - return view; - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - - if (type == Type.NO_REPORTS || type == Type.POSITIVE_TESTED) { - return; - } - - TextView date = view.findViewById(R.id.fragment_reports_header_date); - View info = view.findViewById(R.id.fragment_reports_header_info); - View image = view.findViewById(R.id.fragment_reports_header_image); - Button continueButton = view.findViewById(R.id.fragment_reports_header_continue_button); - TextView subtitle = view.findViewById(R.id.fragment_reports_header_subtitle); - TextView showAllButton = view.findViewById(R.id.fragment_reports_header_show_all_button); - ViewGroup daysContainer = view.findViewById(R.id.fragment_reports_dates_container); - View daysContainerScrollview = view.findViewById(R.id.fragment_reports_dates_scroll_view); - TextView titleTextView = view.findViewById(R.id.fragment_reports_header_title); - - List exposureDays = tracingViewModel.getAppStatusLiveData().getValue().getExposureDays(); - - if (date != null && !exposureDays.isEmpty()) { - date.setText(getDateString(exposureDays.get(exposureDays.size() - 1), true)); - } - - daysContainer.removeAllViews(); - for (ExposureDay exposureDay : exposureDays) { - View itemView = LayoutInflater.from(getContext()).inflate(R.layout.item_reports_exposure_day, daysContainer, false); - TextView itemDate = itemView.findViewById(R.id.exposure_day_textview); - itemDate.setText(getDateString(exposureDay, false)); - daysContainer.addView(itemView, 0); - } - if (exposureDays.size() <= 1) { - showAllButton.setVisibility(View.GONE); - } else { - showAllButton.setVisibility(View.VISIBLE); - } - - showAllButton.setOnClickListener(view1 -> { - if (daysContainerScrollview.getVisibility() == View.VISIBLE) { - subtitle.setText(R.string.meldung_detail_exposed_subtitle_last_encounter); - showAllButton.setText(R.string.meldung_detail_exposed_show_all_button); - ((ReportsFragment) getParentFragment()) - .animateHeaderHeight(false, exposureDays.size(), daysContainerScrollview, date); - } else { - subtitle.setText(R.string.meldung_detail_exposed_subtitle_all_encounters); - showAllButton.setText(R.string.meldung_detail_exposed_show_less_button); - ((ReportsFragment) getParentFragment()) - .animateHeaderHeight(true, exposureDays.size(), daysContainerScrollview, date); - } - }); - showAllButton.setPaintFlags(showAllButton.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); - - if (showAnimationControls) { - - info.setVisibility(View.GONE); - showAllButton.setVisibility(View.GONE); - image.setVisibility(View.VISIBLE); - continueButton.setVisibility(View.VISIBLE); - titleTextView.setText(R.string.meldung_detail_exposed_title); - continueButton.setOnClickListener(view1 -> - ((ReportsFragment) getParentFragment()) - .doHeaderAnimation(info, image, continueButton, showAllButton, exposureDays.size()) - ); - } - } - - private String getDateString(ExposureDay exposureDay, boolean withDiff) { - long timestamp = exposureDay.getExposedDate().getStartOfDay(TimeZone.getDefault()); - if (!withDiff) { - return DateUtils.getFormattedDateWrittenMonth(timestamp); - } - String dateStr = getString(R.string.date_text_before_date).replace("{DATE}", DateUtils.getFormattedDate(timestamp)); - dateStr += " / "; - int daysDiff = DateUtils.getDaysDiff(timestamp); - - if (daysDiff == 0) { - dateStr += getString(R.string.date_today); - } else if (daysDiff == 1) { - dateStr += getString(R.string.date_one_day_ago); - } else { - dateStr += getString(R.string.date_days_ago).replace("{COUNT}", String.valueOf(daysDiff)); - } - return dateStr; - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsOverviewFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsOverviewFragment.kt new file mode 100644 index 000000000..b91adc596 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsOverviewFragment.kt @@ -0,0 +1,93 @@ +package ch.admin.bag.dp3t.reports + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.transition.AutoTransition +import androidx.transition.TransitionManager +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.databinding.FragmentReportsOverviewBinding +import ch.admin.bag.dp3t.extensions.showFragment +import ch.admin.bag.dp3t.storage.SecureStorage +import ch.admin.bag.dp3t.viewmodel.TracingViewModel +import org.crowdnotifier.android.sdk.model.ExposureEvent +import org.dpppt.android.sdk.models.ExposureDay + +class ReportsOverviewFragment : Fragment() { + + companion object { + @JvmStatic + fun newInstance() = ReportsOverviewFragment() + } + + private val crowdNotifierViewModel: CrowdNotifierViewModel by activityViewModels() + private val tracingViewModel: TracingViewModel by activityViewModels() + private val diaryStorage by lazy { DiaryStorage.getInstance(requireContext()) } + private val secureStorage by lazy { SecureStorage.getInstance(requireContext()) } + + private lateinit var binding: FragmentReportsOverviewBinding + private val recyclerAdapter = ReportsRecyclerAdapter() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentReportsOverviewBinding.inflate(inflater) + return binding.apply { + reportsToolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + reportsRecyclerView.adapter = recyclerAdapter + + crowdNotifierViewModel.exposures.observe(viewLifecycleOwner) { + updateRecyclerList(it, tracingViewModel.tracingStatusInterface.exposureDays) + } + tracingViewModel.tracingStatusLiveData.observe(viewLifecycleOwner) { tracingStatus -> + updateRecyclerList(crowdNotifierViewModel.exposures.value ?: listOf(), tracingStatus.exposureDays) + } + recyclerAdapter.setOnClickListener { + showFragment(ReportsFragment.newInstance(it)) + } + if (secureStorage.isReportsHeaderAnimationPending) { + setupSplashScreen() + } + }.root + } + + private fun updateRecyclerList(checkinExposures: List, tracingExposureDays: List) { + val items = checkinExposures.map { + CheckinReportItem(it, diaryStorage.getDiaryEntryWithId(it.id)) + }.toMutableList().apply { + if (tracingExposureDays.isNotEmpty()) { + add(0, ProximityTracingReportItem(tracingExposureDays)) + } + } + if (items.isEmpty()) { + parentFragmentManager.popBackStack() + } + recyclerAdapter.setItems(items) + } + + private fun setupSplashScreen() { + binding.splashReport.apply { + root.isVisible = true + headerContinueButton.isVisible = true + headerShowAllButton.isVisible = false + headerSlogan.isVisible = true + headerImage.isVisible = true + headerInfo.isVisible = false + headerSubtitle.isVisible = false + headerContinueButton.setOnClickListener { hideSplashScreen() } + } + + } + + private fun hideSplashScreen() { + secureStorage.isReportsHeaderAnimationPending = false + val autoTransition = AutoTransition() + autoTransition.duration = 300 + TransitionManager.beginDelayedTransition(binding.root, autoTransition) + binding.splashReport.root.isVisible = false + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsRecyclerAdapter.kt b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsRecyclerAdapter.kt new file mode 100644 index 000000000..c9fcd7c98 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/reports/ReportsRecyclerAdapter.kt @@ -0,0 +1,93 @@ +package ch.admin.bag.dp3t.reports + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.databinding.* +import ch.admin.bag.dp3t.extensions.getDetailsString +import ch.admin.bag.dp3t.util.StringUtil +import org.crowdnotifier.android.sdk.model.ExposureEvent +import org.dpppt.android.sdk.models.ExposureDay +import java.util.* +import kotlin.collections.ArrayList + +class ReportsRecyclerAdapter : RecyclerView.Adapter() { + + private var items: List = emptyList() + private var onClickListener: ((ReportItem) -> Unit)? = null + + fun setItems(newItems: List) { + this.items = ArrayList().apply { addAll(newItems) } + notifyDataSetChanged() + } + + + fun setOnClickListener(listener: (ReportItem) -> Unit) { + this.onClickListener = listener + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ReportViewHolder(ItemReportBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: ReportViewHolder, position: Int) = holder.bind(items[position]) + + override fun getItemCount() = items.size + + + /*--------VIEW HOLDERS-------*/ + + + inner class ReportViewHolder(private val binding: ItemReportBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: ReportItem) { + binding.apply { + cardView.setOnClickListener { onClickListener?.invoke(item) } + place.isVisible = item is CheckinReportItem && item.diaryEntry != null + when (item) { + is ProximityTracingReportItem -> { + reportType.setText(R.string.meldung_detail_exposed_list_card_title_encounters) + reportDetails.text = item.exposures.joinToString("\n") { + StringUtil.getReportDateString( + it.exposedDate.getStartOfDay(TimeZone.getDefault()), + true, + false, + root.context + ) + } + } + is CheckinReportItem -> { + reportType.setText(R.string.meldung_detail_exposed_list_card_title_checkin) + place.text = item.diaryEntry?.venueInfo?.title + reportDetails.text = item.exposure.getDetailsString(root.context) + } + } + } + } + } + +} + +/*--------ITEMS-------*/ + +abstract class ReportItem { + companion object { + const val TYPE_PROXIMITY_TRACING_REPORT = 0 + const val TYPE_CHECKIN_REPORT = 1 + + } + + abstract val type: Int +} + +class ProximityTracingReportItem(val exposures: List) : ReportItem() { + override val type = TYPE_PROXIMITY_TRACING_REPORT +} + +class CheckinReportItem(val exposure: ExposureEvent, val diaryEntry: DiaryEntry?) : ReportItem() { + override val type = TYPE_CHECKIN_REPORT +} + + + diff --git a/app/src/main/java/ch/admin/bag/dp3t/stats/StatsFragment.java b/app/src/main/java/ch/admin/bag/dp3t/stats/StatsFragment.java index 26d5b186c..072b3b593 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/stats/StatsFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/stats/StatsFragment.java @@ -181,7 +181,7 @@ private void setupShareAppButton() { private void setupInfoButtons() { covidcodesInfoButton.setOnClickListener(v -> { ArrayList sections = new ArrayList<>(); - sections.add(new StatsDetailsSection(getString(R.string.stats_covidcodes_total_label), + sections.add(new StatsDetailsSection(getString(R.string.stats_covidcodes_total_header), getString(R.string.stats_covidcodes_total_description))); sections.add(new StatsDetailsSection(getString(R.string.stats_covidcodes_0to2days_label), getString(R.string.stats_covidcodes_0to2days_description))); @@ -204,7 +204,7 @@ private void setupInfoButtons() { getString(R.string.stats_cases_current_description))); sections.add(new StatsDetailsSection(getString(R.string.stats_cases_7day_average_label), getString(R.string.stats_cases_7day_average_description))); - sections.add(new StatsDetailsSection(getString(R.string.stats_cases_rel_prev_week_label), + sections.add(new StatsDetailsSection(getString(R.string.stats_cases_rel_prev_week_popup_header), getString(R.string.stats_cases_rel_prev_week_description))); StatsDetailsDialogFragment fragment = StatsDetailsDialogFragment.newInstance( R.color.purple_main, diff --git a/app/src/main/java/ch/admin/bag/dp3t/storage/SecureStorage.java b/app/src/main/java/ch/admin/bag/dp3t/storage/SecureStorage.java index 7eeadcedc..f4aa90c43 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/storage/SecureStorage.java +++ b/app/src/main/java/ch/admin/bag/dp3t/storage/SecureStorage.java @@ -31,6 +31,7 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; +import ch.admin.bag.dp3t.checkin.models.CheckInState; import ch.admin.bag.dp3t.networking.models.InfoBoxModelCollection; import ch.admin.bag.dp3t.networking.models.TestLocationModel; import ch.admin.bag.dp3t.networking.models.WhatToDoPositiveTestTextsCollection; @@ -47,9 +48,11 @@ public class SecureStorage { private static final String KEY_INFORM_TIME_REQ = "inform_time_req"; private static final String KEY_INFORM_CODE_REQ = "inform_code_req"; private static final String KEY_INFORM_TOKEN_REQ = "inform_token_req"; + private static final String KEY_INFORM_TOKEN_CHECKIN_REQ = "inform_token_chekin_req"; private static final String KEY_ONBOARDING_COMPLETED = "onboarding_completed"; private static final String KEY_UPDATE_BOARDING_VERSION = "update_boarding_version"; private static final String KEY_LAST_SHOWN_CONTACT_ID = "last_shown_contact_id"; + private static final String KEY_CHECK_IN_UPDATE_NOTIFICATION_SHOWN = "check_in_update_notification_shown"; //KEY_LEITFADEN_OPEN_PENDING key value is kept to old value to avoid migration issues private static final String KEY_LEITFADEN_OPEN_PENDING = "hotline_call_pending"; @@ -58,6 +61,7 @@ public class SecureStorage { private static final String KEY_CONFIG_FORCE_UPDATE = "config_do_force_update"; private static final String KEY_CONFIG_HAS_INFOBOX = "has_ghettobox_v2"; private static final String KEY_CONFIG_INFOBOX_COLLECTION = "ghettobox_collection"; + private static final String KEY_CONFIG_TEST_INFORMATION_URLS = "testinformation_urls"; private static final String KEY_ONBOARDING_USER_NOT_IN_PILOT_GROUP = "user_is_not_in_pilot_group"; private static final String KEY_LAST_CONFIG_LOAD_SUCCESS = "last_config_load_success"; private static final String KEY_LAST_CONFIG_LOAD_SUCCESS_APP_VERSION = "last_config_load_success_app_version"; @@ -71,6 +75,15 @@ public class SecureStorage { private static final String KEY_APP_VERSION_CODE = "app_version_code"; private static final String KEY_SCHEDULED_FAKE_WORKER_NAME = "scheduled_fake_worker_name"; private static final String KEY_POSITIVE_REPORT_OLDEST_SHARED_KEY = "positive_report_oldest_shared_key"; + private static final String KEY_CURRENT_CHECK_IN = "KEY_CURRENT_CHECK_IN"; + private static final String KEY_CROWD_NOTIFIER_LAST_KEY_BUNDLE_TAG = "KEY_CROWD_NOTIFIER_LAST_KEY_BUNDLE_TAG"; + private static final String KEY_LAST_SUCCESSFUL_CHECKIN_DOWNLOAD = "KEY_LAST_SUCCESSFUL_CHECKIN_DOWNLOAD"; + private static final String KEY_ONLY_PARTIAL_ONBOARDING_DONE = "KEY_ONLY_PARTIAL_ONBOARDING_DONE"; + private static final String KEY_POSITIVE_REPORT_OLDEST_SHARED_KEY_OR_CHECKIN = + "KEY_POSITIVE_REPORT_OLDEST_SHARED_KEY_OR_CHECKIN"; + private static final String KEY_EXPOSURE_NOTIFICATIONS_ACTIVE_BEFORE_ENTERING_COVIDCODE = + "KEY_EXPOSURE_NOTIFICATIONS_ACTIVE_BEFORE_ENTERING_COVIDCODE"; + private static SecureStorage instance; @@ -113,10 +126,11 @@ public void setInfectedDate(long date) { prefs.edit().putLong(KEY_INFECTED_DATE, date).apply(); } - public void saveInformTimeAndCodeAndToken(String informCode, String informToken) { + public void saveInformTimeAndCodeAndToken(String informCode, String dp3tInformToken, String checkinInformToken) { prefs.edit().putLong(KEY_INFORM_TIME_REQ, System.currentTimeMillis()) .putString(KEY_INFORM_CODE_REQ, informCode) - .putString(KEY_INFORM_TOKEN_REQ, informToken) + .putString(KEY_INFORM_TOKEN_REQ, dp3tInformToken) + .putString(KEY_INFORM_TOKEN_CHECKIN_REQ, checkinInformToken) .apply(); } @@ -124,6 +138,7 @@ public void clearInformTimeAndCodeAndToken() { prefs.edit().remove(KEY_INFORM_TIME_REQ) .remove(KEY_INFORM_CODE_REQ) .remove(KEY_INFORM_TOKEN_REQ) + .remove(KEY_INFORM_TOKEN_CHECKIN_REQ) .apply(); } @@ -135,10 +150,14 @@ public String getLastInformCode() { return prefs.getString(KEY_INFORM_CODE_REQ, null); } - public String getLastInformToken() { + public String getLastDP3TInformToken() { return prefs.getString(KEY_INFORM_TOKEN_REQ, null); } + public String getLastCheckinInformToken() { + return prefs.getString(KEY_INFORM_TOKEN_CHECKIN_REQ, null); + } + public boolean getOnboardingCompleted() { return prefs.getBoolean(KEY_ONBOARDING_COMPLETED, false); } @@ -147,6 +166,14 @@ public void setOnboardingCompleted(boolean completed) { prefs.edit().putBoolean(KEY_ONBOARDING_COMPLETED, completed).apply(); } + public boolean getOnlyPartialOnboardingCompleted() { + return prefs.getBoolean(KEY_ONLY_PARTIAL_ONBOARDING_DONE, false); + } + + public void setOnlyPartialOnboardingCompleted(boolean completed) { + prefs.edit().putBoolean(KEY_ONLY_PARTIAL_ONBOARDING_DONE, completed).apply(); + } + public int getLastShownUpdateBoardingVersion() { return prefs.getInt(KEY_UPDATE_BOARDING_VERSION, 0); } @@ -159,6 +186,22 @@ public int getLastShownContactId() { return prefs.getInt(KEY_LAST_SHOWN_CONTACT_ID, -1); } + public void setLastSuccessfulCheckinDownload(long time) { + prefs.edit().putLong(KEY_LAST_SUCCESSFUL_CHECKIN_DOWNLOAD, time).apply(); + } + + public boolean getCheckInUpdateNotificationShown() { + return prefs.getBoolean(KEY_CHECK_IN_UPDATE_NOTIFICATION_SHOWN, false); + } + + public void setCheckInUpdateNotificationShown(boolean shown) { + prefs.edit().putBoolean(KEY_CHECK_IN_UPDATE_NOTIFICATION_SHOWN, shown).apply(); + } + + public long getLastSuccessfulCheckinDownload() { + return prefs.getLong(KEY_LAST_SUCCESSFUL_CHECKIN_DOWNLOAD, System.currentTimeMillis()); + } + public void setLastShownContactId(int contactId) { prefs.edit().putInt(KEY_LAST_SHOWN_CONTACT_ID, contactId).apply(); } @@ -209,6 +252,24 @@ public InfoBoxModelCollection getInfoBoxCollection() { return gson.fromJson(prefs.getString(KEY_CONFIG_INFOBOX_COLLECTION, "null"), InfoBoxModelCollection.class); } + public void setTestInformationUrls(Map testInformationUrls) { + prefs.edit().putString(KEY_CONFIG_TEST_INFORMATION_URLS, gson.toJson(testInformationUrls)).apply(); + } + + public String getTestInformationUrl(String languageKey) { + String defaultUrl = "https://www.bag.admin.ch/bag/de/home/krankheiten/ausbrueche-epidemien-pandemien/" + + "aktuelle-ausbrueche-epidemien/novel-cov/testen.html"; + + Type testInformationsType = new TypeToken>() { }.getType(); + Map testInfoMap = + gson.fromJson(prefs.getString(KEY_CONFIG_TEST_INFORMATION_URLS, "null"), testInformationsType); + if (testInfoMap == null || !testInfoMap.containsKey(languageKey)) { + return defaultUrl; + } else { + return testInfoMap.get(languageKey); + } + } + public boolean isUserNotInPilotGroup() { return prefs.getBoolean(KEY_ONBOARDING_USER_NOT_IN_PILOT_GROUP, false); } @@ -337,6 +398,22 @@ public void setPositiveReportOldestSharedKey(long setPositiveReportOldestSharedK prefs.edit().putLong(KEY_POSITIVE_REPORT_OLDEST_SHARED_KEY, setPositiveReportOldestSharedKey).apply(); } + public long getPositiveReportOldestSharedKeyOrCheckin() { + return prefs.getLong(KEY_POSITIVE_REPORT_OLDEST_SHARED_KEY_OR_CHECKIN, -1L); + } + + public void setPositiveReportOldestSharedKeyOrCheckin(long oldestSharedKeyOrCheckin) { + prefs.edit().putLong(KEY_POSITIVE_REPORT_OLDEST_SHARED_KEY_OR_CHECKIN, oldestSharedKeyOrCheckin).apply(); + } + + public boolean getExposureNotifcationsActiveBeforeEnteringCovidcode() { + return prefs.getBoolean(KEY_EXPOSURE_NOTIFICATIONS_ACTIVE_BEFORE_ENTERING_COVIDCODE, false); + } + + public void setExposureNotifcationsActiveBeforeEnteringCovidcode(boolean active) { + prefs.edit().putBoolean(KEY_EXPOSURE_NOTIFICATIONS_ACTIVE_BEFORE_ENTERING_COVIDCODE, active).apply(); + } + private synchronized SharedPreferences initializeSharedPreferences(@NonNull Context context) { try { return createEncryptedSharedPreferences(context); @@ -345,6 +422,23 @@ private synchronized SharedPreferences initializeSharedPreferences(@NonNull Cont } } + public void setCheckInState(CheckInState checkInState) { + prefs.edit().putString(KEY_CURRENT_CHECK_IN, gson.toJson(checkInState)).apply(); + } + + public CheckInState getCheckInState() { + return gson.fromJson(prefs.getString(KEY_CURRENT_CHECK_IN, null), CheckInState.class); + } + + public void setCrowdNotifierLastKeyBundleTag(long lastSync) { + prefs.edit().putLong(KEY_CROWD_NOTIFIER_LAST_KEY_BUNDLE_TAG, lastSync).apply(); + } + + public long getCrowdNotifierLastKeyBundleTag() { + return prefs.getLong(KEY_CROWD_NOTIFIER_LAST_KEY_BUNDLE_TAG, 0); + } + + /** * Create or obtain an encrypted SharedPreferences instance. Note that this method is synchronized because the AndroidX * Security diff --git a/app/src/main/java/ch/admin/bag/dp3t/travel/TravelFragment.java b/app/src/main/java/ch/admin/bag/dp3t/travel/TravelFragment.java index d6e5f5fc5..3680a0a9d 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/travel/TravelFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/travel/TravelFragment.java @@ -21,6 +21,7 @@ import androidx.fragment.app.Fragment; import java.util.List; +import java.util.Locale; import ch.admin.bag.dp3t.R; import ch.admin.bag.dp3t.storage.SecureStorage; @@ -74,7 +75,7 @@ private void setupCountryList() { ImageView flagImageView = countryItemView.findViewById(R.id.flag_icon); TextView flagTextView = countryItemView.findViewById(R.id.flag_cc); - String idName = "flag_" + countryCode.toLowerCase(); + String idName = "flag_" + countryCode.toLowerCase(Locale.GERMAN); int drawableRes = UiUtils.getDrawableResourceByName(requireContext(), idName); if (drawableRes != 0) { flagImageView.setImageResource(drawableRes); diff --git a/app/src/main/java/ch/admin/bag/dp3t/travel/TravelUtils.java b/app/src/main/java/ch/admin/bag/dp3t/travel/TravelUtils.java index c38888e96..dec3e5780 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/travel/TravelUtils.java +++ b/app/src/main/java/ch/admin/bag/dp3t/travel/TravelUtils.java @@ -42,7 +42,7 @@ public static void inflateFlagFlow(Flow flowConstraint, List countries) ImageView flagImageView = flagView.findViewById(R.id.flag_icon); TextView flagTextView = flagView.findViewById(R.id.flag_cc); - String idName = "flag_" + country.toLowerCase(); + String idName = "flag_" + country.toLowerCase(Locale.GERMAN); int drawableRes = UiUtils.getDrawableResourceByName(context, idName); if (drawableRes != 0) { flagImageView.setImageResource(drawableRes); diff --git a/app/src/main/java/ch/admin/bag/dp3t/updateboarding/InteroperabilityUpdateBoardingFragment.java b/app/src/main/java/ch/admin/bag/dp3t/updateboarding/InteroperabilityUpdateBoardingFragment.java deleted file mode 100644 index a0250d26e..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/updateboarding/InteroperabilityUpdateBoardingFragment.java +++ /dev/null @@ -1,87 +0,0 @@ -package ch.admin.bag.dp3t.updateboarding; - -import android.os.Bundle; -import android.text.Html; -import android.view.View; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; - -import ch.admin.bag.dp3t.R; -import ch.admin.bag.dp3t.util.AssetUtil; -import ch.admin.bag.dp3t.util.UlTagHandler; -import ch.admin.bag.dp3t.util.UrlUtil; - -public class InteroperabilityUpdateBoardingFragment extends Fragment { - - public static InteroperabilityUpdateBoardingFragment newInstance() { - return new InteroperabilityUpdateBoardingFragment(); - } - - public InteroperabilityUpdateBoardingFragment() { - super(R.layout.fragment_update_boarding_interoperability); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - TextView termsOfUseTextview = view.findViewById(R.id.terms_of_use_textview); - TextView dataProtectionTextView = view.findViewById(R.id.data_protection_textview); - termsOfUseTextview.setText(Html.fromHtml(AssetUtil.getTermsOfUse(getContext()), null, new UlTagHandler())); - dataProtectionTextView.setText(Html.fromHtml(AssetUtil.getDataProtection(getContext()), null, new UlTagHandler())); - - ImageView termsOfUseChevron = view.findViewById(R.id.terms_of_use_chevron_imageview); - ImageView dataProtectionChevron = view.findViewById(R.id.data_protection_chevron_imageview); - - View dataProtectionToOnlineVersionButton = - view.findViewById(R.id.onboarding_disclaimer_data_protection_to_online_version_button); - View termsOfUseToOnlineVersionButton = view.findViewById(R.id.onboarding_disclaimer_terms_of_use_to_online_version_button); - - View termsOfUseContainer = view.findViewById(R.id.onboarding_disclaimer_terms_of_use_container); - View dataProtectionContainer = view.findViewById(R.id.onboarding_disclaimer_data_protection_container); - - view.findViewById(R.id.data_protection_header_container).setOnClickListener(v -> { - if (dataProtectionContainer.getVisibility() == View.VISIBLE) { - dataProtectionContainer.setVisibility(View.GONE); - v.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white)); - } else { - dataProtectionContainer.setVisibility(View.VISIBLE); - v.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.grey_light)); - } - dataProtectionChevron.animate() - .rotation(dataProtectionChevron.getRotation() + 180) - .setDuration(getResources().getInteger(android.R.integer.config_shortAnimTime)) - .start(); - }); - - view.findViewById(R.id.conditions_of_use_header_container).setOnClickListener(v -> { - if (termsOfUseContainer.getVisibility() == View.VISIBLE) { - termsOfUseContainer.setVisibility(View.GONE); - v.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white)); - } else { - termsOfUseContainer.setVisibility(View.VISIBLE); - v.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.grey_light)); - } - termsOfUseChevron.animate() - .rotation(termsOfUseChevron.getRotation() + 180) - .setDuration(getResources().getInteger(android.R.integer.config_shortAnimTime)) - .start(); - }); - - dataProtectionToOnlineVersionButton.setOnClickListener(v -> openOnlineVersion()); - termsOfUseToOnlineVersionButton.setOnClickListener(v -> openOnlineVersion()); - - Button okButton = view.findViewById(R.id.updateboarding_ok_button); - okButton.setOnClickListener(v -> ((UpdateBoardingActivity) getActivity()).finishUpdateBoarding()); - } - - private void openOnlineVersion() { - UrlUtil.openUrl(getContext(), getString(R.string.onboarding_disclaimer_legal_button_url)); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/updateboarding/UpdateBoardingActivity.java b/app/src/main/java/ch/admin/bag/dp3t/updateboarding/UpdateBoardingActivity.java deleted file mode 100644 index 9c74d1a32..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/updateboarding/UpdateBoardingActivity.java +++ /dev/null @@ -1,46 +0,0 @@ -package ch.admin.bag.dp3t.updateboarding; - -import android.content.Intent; -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentActivity; - -import org.dpppt.android.sdk.DP3T; - -import ch.admin.bag.dp3t.R; - -public class UpdateBoardingActivity extends FragmentActivity { - - // Increment this number for each new Update Boarding - public static final int UPDATE_BOARDING_VERSION = 1; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_update_boarding); - if (savedInstanceState == null) { - showFirstUpdateBoardingFragment(); - } - } - - private void showFirstUpdateBoardingFragment() { - // Replace with new UpdateBoarding Fragment - getSupportFragmentManager() - .beginTransaction() - .add(R.id.main_fragment_container, InteroperabilityUpdateBoardingFragment.newInstance()) - .commit(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - super.onActivityResult(requestCode, resultCode, data); - DP3T.onActivityResult(this, requestCode, resultCode, data); - } - - public void finishUpdateBoarding() { - setResult(RESULT_OK); - finish(); - overridePendingTransition(R.anim.fragment_open_enter, R.anim.fragment_open_exit); - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/AssetUtil.java b/app/src/main/java/ch/admin/bag/dp3t/util/AssetUtil.java index 550e904cd..27e7cef53 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/AssetUtil.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/AssetUtil.java @@ -34,8 +34,6 @@ public class AssetUtil { private static final String FILE_NAME_IMPRESSUM = "impressum.html"; private static final String REPLACE_STRING_VERSION = "{VERSION}"; - private static final String REPLACE_STRING_APPVERSION = "{APPVERSION}"; - private static final String REPLACE_STRING_RELEASEDATE = "{RELEASEDATE}"; private static final String REPLACE_STRING_BUILDNR = "{BUILD}"; public static String getImpressumBaseUrl(Context context) { @@ -91,16 +89,14 @@ public static String loadImpressumHtmlFile(Context context, String filename) { String impressum = html.toString(); StringBuilder versionString = new StringBuilder(BuildConfig.VERSION_NAME) .append(", ") - .append(org.dpppt.android.sdk.BuildConfig.VERSION_NAME); + .append(org.dpppt.android.sdk.BuildConfig.LIBRARY_VERSION_NAME); StringBuilder buildString = new StringBuilder(String.valueOf(BuildConfig.BUILD_TIME)) .append(" / ") .append(BuildConfig.FLAVOR); impressum = impressum.replace(REPLACE_STRING_VERSION, versionString); - impressum = impressum.replace(REPLACE_STRING_APPVERSION, BuildConfig.VERSION_NAME); SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy"); sdf.setTimeZone(TimeZone.getTimeZone("Europe/Zurich")); - impressum = impressum.replace(REPLACE_STRING_RELEASEDATE, sdf.format(BuildConfig.BUILD_TIME)); impressum = impressum.replace(REPLACE_STRING_BUILDNR, buildString); return impressum; } catch (IOException e) { diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/DateUtils.java b/app/src/main/java/ch/admin/bag/dp3t/util/DateUtils.java index 0ad6c8dc0..c4182fd92 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/DateUtils.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/DateUtils.java @@ -9,9 +9,12 @@ */ package ch.admin.bag.dp3t.util; +import android.content.Context; + import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Calendar; import java.util.Date; import java.util.Locale; import java.util.TimeZone; @@ -19,20 +22,34 @@ import org.dpppt.android.sdk.models.DayDate; +import ch.admin.bag.dp3t.R; + public class DateUtils { private static final DateFormat DATE_TIME_FORMAT = SimpleDateFormat.getDateTimeInstance(); private static final DateFormat DATE_FORMAT = SimpleDateFormat.getDateInstance(); - public static int getDaysDiff(long date) { + public static int getDaysDiff(long timestamp) { try { - return (int) TimeUnit.DAYS.convert(System.currentTimeMillis() - date, TimeUnit.MILLISECONDS); + return (int) TimeUnit.DAYS.convert( + getLocalStartOfDayTimestamp(System.currentTimeMillis()) - getLocalStartOfDayTimestamp(timestamp), + TimeUnit.MILLISECONDS); } catch (Exception e) { e.printStackTrace(); return 0; } } + private static long getLocalStartOfDayTimestamp(long timestamp) { + Calendar calendar = Calendar.getInstance(TimeZone.getDefault()); + calendar.setTime(new Date(timestamp)); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + return calendar.getTime().getTime(); + } + public static int getDaysDiffUntil(DayDate from, DayDate to) { try { return (int) TimeUnit.DAYS.convert(to.getStartOfDayTimestamp() - from.getStartOfDayTimestamp(), TimeUnit.MILLISECONDS); @@ -60,6 +77,16 @@ public static String getFormattedDateWrittenMonth(long date, TimeZone timezone) return sdf.format(new Date(date)); } + public static String getFormattedWeekdayWithDate(long timestamp, Context context) { + int daysDiff = getDaysDiff(timestamp); + if (daysDiff == 0) { + return context.getString(R.string.date_today); + } else { + SimpleDateFormat sdf = new SimpleDateFormat("EEEE, dd.MM.", new Locale(context.getString(R.string.language_key))); + return sdf.format(new Date(timestamp)); + } + } + public static Date getParsedDateStats(String date) { if (date == null) { return null; diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/NotificationStateHelper.java b/app/src/main/java/ch/admin/bag/dp3t/util/NotificationStateHelper.java index 79f3b92cb..6cfc551a5 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/NotificationStateHelper.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/NotificationStateHelper.java @@ -7,7 +7,6 @@ * * SPDX-License-Identifier: MPL-2.0 */ - package ch.admin.bag.dp3t.util; import android.content.Context; @@ -34,10 +33,9 @@ public static void updateStatusView(View statusView, NotificationState state, lo TextView textView = statusView.findViewById(R.id.status_text); ImageView illustrationView = statusView.findViewById(R.id.status_illustration); int titleColor = ContextCompat.getColor(context, NotificationState.getTitleTextColor(state)); - int textColor = ContextCompat.getColor(context, NotificationState.geTextColor(state)); + int textColor = ContextCompat.getColor(context, NotificationState.getTextColor(state)); titleView.setTextColor(titleColor); textView.setTextColor(textColor); - iconView.setImageTintList(ColorStateList.valueOf(titleColor)); if (NotificationState.getTitle(state) != -1) { titleView.setText(NotificationState.getTitle(state)); @@ -54,6 +52,13 @@ public static void updateStatusView(View statusView, NotificationState state, lo if (NotificationState.getIcon(state) != -1) { iconView.setImageResource(NotificationState.getIcon(state)); iconView.setVisibility(View.VISIBLE); + + Integer iconColor = NotificationState.getIconColor(state); + if (iconColor != null) { + iconView.setImageTintList(ColorStateList.valueOf(ContextCompat.getColor(context, iconColor))); + } else { + iconView.setImageTintList(null); + } } else { iconView.setVisibility(View.GONE); } @@ -75,8 +80,8 @@ public static void updateStatusView(View statusView, NotificationState state, lo triangle.setVisibility(View.VISIBLE); triangle.setImageResource(R.drawable.triangle_status_exposed); infoContainer.setVisibility(View.VISIBLE); - infoText.setText(R.string.exposed_info_answer_questions_in_leitfaden); - infoTel.setText(R.string.exposed_info_swisscovid_leitfaden); + infoText.setVisibility(View.GONE); + infoTel.setVisibility(View.GONE); infoSince.setVisibility(View.VISIBLE); if (daySinceExposed == 0) { String string = context.getString(R.string.date_today); diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/NotificationUtil.java b/app/src/main/java/ch/admin/bag/dp3t/util/NotificationUtil.java index 016918e7f..02cdef94c 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/NotificationUtil.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/NotificationUtil.java @@ -20,8 +20,13 @@ import androidx.core.app.NotificationCompat; import androidx.core.app.TaskStackBuilder; +import org.dpppt.android.sdk.internal.history.HistoryDatabase; +import org.dpppt.android.sdk.internal.history.HistoryEntry; +import org.dpppt.android.sdk.internal.history.HistoryEntryType; + import ch.admin.bag.dp3t.MainActivity; import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.debug.DebugFragment; public class NotificationUtil { @@ -32,6 +37,7 @@ public class NotificationUtil { public static final int NOTIFICATION_ID_CONTACT = 42; public static final int NOTIFICATION_ID_UPDATE = 43; public static final int NOTIFICATION_ID_REMINDER = 44; + public static final int NOTIFICATION_ID_CHECKIN_UPDATE = 45; public static void generateContactNotification(Context context) { @@ -59,10 +65,19 @@ public static void generateContactNotification(Context context) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (DebugFragment.EXISTS) { + HistoryDatabase.getInstance(context).addEntry( + new HistoryEntry( + HistoryEntryType.NOTIFICATION, "Showing new message notification", false, + System.currentTimeMillis() + ) + ); + } notificationManager.notify(NOTIFICATION_ID_CONTACT, notification); NotificationRepeatWorker.startWorker(context); } + public static void showReminderNotification(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createNotificationChannel(CHANNEL_ID_REMINDER, context.getString(R.string.android_reminder_channel_name), @@ -74,6 +89,14 @@ public static void showReminderNotification(Context context) { Notification notification = createNotification(title, message, pendingIntent, CHANNEL_ID_REMINDER, context); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (DebugFragment.EXISTS) { + HistoryDatabase.getInstance(context).addEntry( + new HistoryEntry( + HistoryEntryType.NOTIFICATION, "Showing activate tracing notification", false, + System.currentTimeMillis() + ) + ); + } notificationManager.notify(message.hashCode(), notification); } diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/StringUtil.java b/app/src/main/java/ch/admin/bag/dp3t/util/StringUtil.java deleted file mode 100644 index 2d6491f6d..000000000 --- a/app/src/main/java/ch/admin/bag/dp3t/util/StringUtil.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2020 Ubique Innovation AG - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ - -package ch.admin.bag.dp3t.util; - -import android.graphics.Typeface; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.style.StyleSpan; -import androidx.annotation.NonNull; - -import java.math.BigInteger; - -public class StringUtil { - - /** - * Creates a spannable where the {@code boldString} is set to bold within the {@code fullString}. - * Be aware that this only applies to the first occurence. - * - * @param fullString The entire string - * @param boldString The partial string to be made bold - * @return A partially bold spannable - */ - public static Spannable makePartiallyBold(@NonNull String fullString, @NonNull String boldString) { - int start = fullString.indexOf(boldString); - if (start >= 0) { - return makePartiallyBold(fullString, start, start + boldString.length()); - } - return new SpannableString(fullString); - } - - public static SpannableString makePartiallyBold(@NonNull String string, int start, int end) { - SpannableString result = new SpannableString(string); - result.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - return result; - } - - public static String toHex(byte[] array) { - BigInteger bi = new BigInteger(1, array); - String hex = bi.toString(16); - int paddingLength = (array.length * 2) - hex.length(); - if(paddingLength > 0) - return String.format("%0" + paddingLength + "d", 0) + hex; - else - return hex; - } - -} diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/StringUtil.kt b/app/src/main/java/ch/admin/bag/dp3t/util/StringUtil.kt new file mode 100644 index 000000000..2c55bbe8c --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/util/StringUtil.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2020 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ +package ch.admin.bag.dp3t.util + +import android.content.Context +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableString +import android.text.Spanned +import android.text.style.StyleSpan +import ch.admin.bag.dp3t.R +import java.math.BigInteger +import java.util.* +import java.util.concurrent.TimeUnit + +object StringUtil { + private val ONE_HOUR = TimeUnit.HOURS.toMillis(1) + + /** + * Creates a spannable where the `boldString` is set to bold within the `fullString`. + * Be aware that this only applies to the first occurence. + * @param fullString The entire string + * @param boldString The partial string to be made bold + * @return A partially bold spannable + */ + fun makePartiallyBold(fullString: String, boldString: String): Spannable { + val start = fullString.indexOf(boldString) + return if (start >= 0) { + makePartiallyBold(fullString, start, start + boldString.length) + } else SpannableString(fullString) + } + + @JvmStatic + fun makePartiallyBold(string: String, start: Int, end: Int): SpannableString { + val result = SpannableString(string) + result.setSpan(StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + return result + } + + @JvmStatic + fun toHex(array: ByteArray): String { + val bi = BigInteger(1, array) + val hex = bi.toString(16) + val paddingLength = array.size * 2 - hex.length + return if (paddingLength > 0) String.format("%0" + paddingLength + "d", 0) + hex else hex + } + + @JvmStatic + fun getHourMinuteTimeString(timeStamp: Long, delimiter: String): String { + val time = Calendar.getInstance() + time.timeInMillis = timeStamp + return prependZero(time[Calendar.HOUR_OF_DAY]) + delimiter + prependZero(time[Calendar.MINUTE]) + } + + private fun prependZero(timeUnit: Int): String { + return if (timeUnit < 10) { + "0$timeUnit" + } else { + timeUnit.toString() + } + } + + /** + * Formats a duration in milliseconds to a String of hours and minutes with units. e.g. "1 h 12 min" + * duration is more than 10 hours + * @param duration in milliseconds + * @return a formatted duration String + */ + fun getShortDurationStringWithUnits(duration: Long, context: Context): String { + return if (duration < ONE_HOUR) { + val minutes = TimeUnit.MILLISECONDS.toMinutes(duration) + minutes.toMinutesString(context) + } else { + val hours = TimeUnit.MILLISECONDS.toHours(duration) + val minutes = TimeUnit.MILLISECONDS.toMinutes(duration) - TimeUnit.HOURS.toMinutes(hours) + if (minutes > 0L) { + "${hours.toHoursString(context)} ${minutes.toMinutesString(context)}" + } else { + hours.toHoursString(context) + } + } + } + + fun Long.toHoursString(context: Context): String { + return context.getString(R.string.reminder_option_hours).replace("{HOURS}", this.toString()) + } + + fun Long.toMinutesString(context: Context): String { + return context.getString(R.string.reminder_option_minutes).replace("{MINUTES}", this.toString()) + } + + /** + * Formats a duration in milliseconds to a String of hours, minutes and seconds, or to only hours and minutes if the + * duration is more than 10 hours + * @param duration in milliseconds + * @return a formatted duration String + */ + @JvmStatic + fun getShortDurationString(duration: Long): String { + return if (duration >= TimeUnit.HOURS.toMillis(10)) { + String.format( + Locale.GERMAN, "%d:%02d", + TimeUnit.MILLISECONDS.toHours(duration), + TimeUnit.MILLISECONDS + .toMinutes(duration - TimeUnit.HOURS.toMillis(TimeUnit.MILLISECONDS.toHours(duration))) + ) + } else { + getDurationString(duration) + } + } + + fun getDurationString(duration: Long): String { + return if (duration >= ONE_HOUR) { + String.format( + Locale.GERMAN, "%d:%02d:%02d", + TimeUnit.MILLISECONDS.toHours(duration), + TimeUnit.MILLISECONDS.toMinutes( + duration - TimeUnit.HOURS.toMillis( + TimeUnit.MILLISECONDS.toHours( + duration + ) + ) + ), + TimeUnit.MILLISECONDS.toSeconds( + duration - TimeUnit.MINUTES.toMillis( + TimeUnit.MILLISECONDS.toMinutes( + duration + ) + ) + ) + ) + } else { + String.format( + Locale.GERMAN, "%02d:%02d", + TimeUnit.MILLISECONDS.toMinutes(duration), + TimeUnit.MILLISECONDS.toSeconds( + duration - TimeUnit.MINUTES.toMillis( + TimeUnit.MILLISECONDS.toMinutes( + duration + ) + ) + ) + ) + } + } + + fun getDaysAgoString(timeStamp: Long, context: Context): String { + val daysAgo = DateUtils.getDaysDiff(timeStamp).toLong() + return if (daysAgo <= 0) { + context.resources.getString(R.string.date_today) + } else if (daysAgo == 1L) { + context.resources.getString(R.string.date_one_day_ago) + } else { + context.resources.getString(R.string.date_days_ago) + .replace("{COUNT}", daysAgo.toString()) + } + } + + fun getReportDateString(timestamp: Long, withDiff: Boolean, withPrefix: Boolean, context: Context): String { + if (!withDiff) { + return DateUtils.getFormattedDateWrittenMonth(timestamp) + } + var dateStr: String + dateStr = if (withPrefix) { + context.getString(R.string.date_text_before_date) + .replace("{DATE}", DateUtils.getFormattedDate(timestamp)) + } else { + DateUtils.getFormattedDate(timestamp) + } + dateStr += " / " + val daysDiff = DateUtils.getDaysDiff(timestamp) + dateStr += if (daysDiff == 0) { + context.getString(R.string.date_today) + } else if (daysDiff == 1) { + context.getString(R.string.date_one_day_ago) + } else { + context.getString(R.string.date_days_ago).replace("{COUNT}", daysDiff.toString()) + } + return dateStr + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/TracingErrorStateHelper.java b/app/src/main/java/ch/admin/bag/dp3t/util/TracingErrorStateHelper.java index bea66b2a3..a5be5d824 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/TracingErrorStateHelper.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/TracingErrorStateHelper.java @@ -25,6 +25,7 @@ import org.dpppt.android.sdk.TracingStatus; import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState; public class TracingErrorStateHelper { @@ -156,39 +157,62 @@ public static boolean isReportsErrorState(TracingStatus.ErrorState error) { return possibleNotificationErrorStatesOrderedByPriority.contains(error); } + public static void hideErrorView(View tracingErrorView) { + tracingErrorView.setVisibility(View.GONE); + } + + public static void updateErrorView(View tracingErrorView, CrowdNotifierErrorState errorState) { + if (errorState == null) { + tracingErrorView.setVisibility(View.GONE); + return; + } + tracingErrorView.setVisibility(View.VISIBLE); + + updateErrorView(tracingErrorView, errorState.getImageResId(), errorState.getTitleResId(), + tracingErrorView.getResources().getString(errorState.getTextResId()), "CNNET", errorState.getActionResId()); + } + public static void updateErrorView(View tracingErrorView, TracingStatus.ErrorState errorState) { if (errorState == null) { tracingErrorView.setVisibility(View.GONE); return; } tracingErrorView.setVisibility(View.VISIBLE); + + updateErrorView(tracingErrorView, TracingErrorStateHelper.getIcon(errorState), + TracingErrorStateHelper.getTitle(errorState), + TracingErrorStateHelper.getText(tracingErrorView.getContext(), errorState), + TracingErrorStateHelper.getErrorCode(errorState), + TracingErrorStateHelper.getButtonText(errorState)); + } + + private static void updateErrorView(View tracingErrorView, @DrawableRes int icon, @StringRes int title, String text, + String errorCodeString, @StringRes int buttonTitle) { ImageView iconView = tracingErrorView.findViewById(R.id.error_status_image); TextView titleView = tracingErrorView.findViewById(R.id.error_status_title); TextView textView = tracingErrorView.findViewById(R.id.error_status_text); TextView errorCode = tracingErrorView.findViewById(R.id.error_status_code); TextView buttonView = tracingErrorView.findViewById(R.id.error_status_button); - iconView.setImageResource(TracingErrorStateHelper.getIcon(errorState)); + iconView.setImageResource(icon); iconView.setVisibility(View.VISIBLE); - - titleView.setText(TracingErrorStateHelper.getTitle(errorState)); + titleView.setText(title); titleView.setVisibility(View.VISIBLE); - if (TracingErrorStateHelper.getText(textView.getContext(), errorState) != null) { - textView.setText(TracingErrorStateHelper.getText(textView.getContext(), errorState)); + if (text != null) { + textView.setText(text); textView.setVisibility(View.VISIBLE); } else { textView.setVisibility(View.GONE); } - if (!TextUtils.isEmpty(TracingErrorStateHelper.getErrorCode(errorState))) { - errorCode.setText(TracingErrorStateHelper.getErrorCode(errorState)); + if (!TextUtils.isEmpty(errorCodeString)) { + errorCode.setText(errorCodeString); errorCode.setVisibility(View.VISIBLE); } else { errorCode.setVisibility(View.GONE); } - - if (TracingErrorStateHelper.getButtonText(errorState) != -1) { - buttonView.setText(TracingErrorStateHelper.getButtonText(errorState)); + if (buttonTitle != -1) { + buttonView.setText(buttonTitle); buttonView.setVisibility(View.VISIBLE); buttonView.setPaintFlags(buttonView.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); } else { diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/TracingStatusHelper.java b/app/src/main/java/ch/admin/bag/dp3t/util/TracingStatusHelper.java index 930ae7d4f..de977eb2a 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/TracingStatusHelper.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/TracingStatusHelper.java @@ -9,6 +9,7 @@ */ package ch.admin.bag.dp3t.util; +import android.app.Activity; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Paint; @@ -17,17 +18,21 @@ import android.widget.LinearLayout; import android.widget.TextView; import androidx.core.content.ContextCompat; +import androidx.core.widget.ImageViewCompat; import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState; import ch.admin.bag.dp3t.home.model.TracingState; +import ch.admin.bag.dp3t.storage.SecureStorage; +import ch.admin.bag.dp3t.viewmodel.TracingViewModel; public class TracingStatusHelper { - public static void updateStatusView(View statusView, TracingState state, boolean isHomeFragment) { - updateStatusView(statusView, state, true, isHomeFragment); + public static void updateStatusView(View statusView, TracingState state) { + updateStatusView(statusView, state, true); } - public static void updateStatusView(View statusView, TracingState state, boolean displayIllu, boolean isHomeFragment) { + public static void updateStatusView(View statusView, TracingState state, boolean displayIllu) { Context context = statusView.getContext(); if (TracingState.getBackgroundColor(state) != -1) { statusView.findViewById(R.id.status_background) @@ -49,8 +54,8 @@ public static void updateStatusView(View statusView, TracingState state, boolean } else { titleView.setVisibility(View.GONE); } - if (TracingState.getText(state, isHomeFragment) != -1) { - textView.setText(TracingState.getText(state, isHomeFragment)); + if (TracingState.getText(state) != -1) { + textView.setText(TracingState.getText(state)); textView.setVisibility(View.VISIBLE); } else { textView.setVisibility(View.GONE); @@ -75,29 +80,30 @@ public static void updateStatusView(View statusView, TracingState state, boolean } } + public static void showFinishPartialOnboarding(View tracingErrorView) { + tracingErrorView.setBackgroundResource(R.color.dark_main); + TracingErrorStateHelper.updateErrorView(tracingErrorView, CrowdNotifierErrorState.ONLY_INSTANT_ONBOARDING_DONE); + int white = ContextCompat.getColor(tracingErrorView.getContext(), R.color.white); + ((TextView) tracingErrorView.findViewById(R.id.error_status_title)).setTextColor(white); + ((TextView) tracingErrorView.findViewById(R.id.error_status_text)).setTextColor(white); + ((TextView) tracingErrorView.findViewById(R.id.error_status_button)).setTextColor(white); + tracingErrorView.findViewById(R.id.error_status_code).setVisibility(View.GONE); + } + public static void showTracingDeactivated(View tracingErrorView, boolean isHomeFragment) { + int darkMainColor = ContextCompat.getColor(tracingErrorView.getContext(), R.color.dark_main); + ImageView iconView = tracingErrorView.findViewById(R.id.error_status_image); - if (TracingState.getIcon(TracingState.NOT_ACTIVE) != -1) { - iconView.setImageResource(TracingState.getIcon(TracingState.NOT_ACTIVE)); - iconView.setVisibility(View.VISIBLE); - } else { - iconView.setVisibility(View.GONE); - } + iconView.setImageResource(R.drawable.ic_info); + ImageViewCompat.setImageTintList(iconView, ColorStateList.valueOf(darkMainColor)); + TextView titleView = tracingErrorView.findViewById(R.id.error_status_title); - if (TracingState.getTitle(TracingState.NOT_ACTIVE) != -1) { - titleView.setText(TracingState.getTitle(TracingState.NOT_ACTIVE)); - titleView.setVisibility(View.VISIBLE); - } else { - titleView.setVisibility(View.GONE); - } + titleView.setText(TracingState.getTitle(TracingState.NOT_ACTIVE)); + titleView.setTextColor(darkMainColor); TextView textView = tracingErrorView.findViewById(R.id.error_status_text); - if (TracingState.getText(TracingState.NOT_ACTIVE, isHomeFragment) != -1) { - textView.setText(TracingState.getText(TracingState.NOT_ACTIVE, isHomeFragment)); - textView.setVisibility(View.VISIBLE); - } else { - textView.setVisibility(View.GONE); - } + textView.setText(TracingState.getText(TracingState.NOT_ACTIVE)); + tracingErrorView.findViewById(R.id.error_status_code).setVisibility(View.GONE); TextView buttonView = tracingErrorView.findViewById(R.id.error_status_button); if (isHomeFragment) { @@ -109,4 +115,15 @@ public static void showTracingDeactivated(View tracingErrorView, boolean isHomeF } } + public static void resetStateAfterIsolation(Activity activity, TracingViewModel tracingViewModel) { + tracingViewModel.getTracingStatusInterface().resetInfectionStatus(activity); + SecureStorage secureStorage = SecureStorage.getInstance(activity); + secureStorage.setIsolationEndDialogTimestamp(-1L); + secureStorage.setPositiveReportOldestSharedKey(-1L); + secureStorage.setPositiveReportOldestSharedKeyOrCheckin(-1L); + if (secureStorage.getExposureNotifcationsActiveBeforeEnteringCovidcode()) { + tracingViewModel.enableTracing(activity, () -> {}, (e) -> {}, () -> {}); + } + } + } diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/UiUtils.java b/app/src/main/java/ch/admin/bag/dp3t/util/UiUtils.java index e2715ecde..ef6a2d71d 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/util/UiUtils.java +++ b/app/src/main/java/ch/admin/bag/dp3t/util/UiUtils.java @@ -7,7 +7,6 @@ * * SPDX-License-Identifier: MPL-2.0 */ - package ch.admin.bag.dp3t.util; import android.content.Context; diff --git a/app/src/main/java/ch/admin/bag/dp3t/util/UserUploadInfoExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/util/UserUploadInfoExtensions.kt new file mode 100644 index 000000000..a58f3771a --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/util/UserUploadInfoExtensions.kt @@ -0,0 +1,16 @@ +package ch.admin.bag.dp3t.util + +import ch.admin.bag.dp3t.checkin.models.UploadVenueInfo +import com.google.protobuf.ByteString +import org.crowdnotifier.android.sdk.model.UserUploadInfo + +fun UserUploadInfo.toUploadVenueInfo(): UploadVenueInfo { + return UploadVenueInfo.newBuilder() + .setPreId(ByteString.copyFrom(preId)) + .setTimeKey(ByteString.copyFrom(timeKey)) + .setNotificationKey(ByteString.copyFrom(notificationKey)) + .setIntervalStartMs(intervalStartMs) + .setIntervalEndMs(intervalEndMs) + .setFake(ByteString.copyFrom(ByteArray(1) { 0 })) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/view/DateTimePicker.kt b/app/src/main/java/ch/admin/bag/dp3t/view/DateTimePicker.kt new file mode 100644 index 000000000..4c5568e9a --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/view/DateTimePicker.kt @@ -0,0 +1,144 @@ +package ch.admin.bag.dp3t.view + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.res.ResourcesCompat +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.databinding.ViewDatetimePickerBinding +import ch.admin.bag.dp3t.util.DateUtils +import com.shawnlin.numberpicker.NumberPicker +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import java.util.* + +class DateTimePicker @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding = ViewDatetimePickerBinding.inflate(LayoutInflater.from(context), this) + + private val zoneId = ZoneId.systemDefault() + private val now = LocalDateTime.now() + private var previousDateTime = now + + private val daysInPast = 180 + private val daysInFuture = 0 + + private lateinit var dateFormatter: NumberPicker.Formatter + private lateinit var hourFormatter: NumberPicker.Formatter + private lateinit var minuteFormatter: NumberPicker.Formatter + + private var changeListener: OnDateTimeChangedListener? = null + + init { + initializeFormatters() + initializePickers() + updatePickerValues() + } + + private fun initializeFormatters() { + val baseDate = now.toLocalDate() + + dateFormatter = NumberPicker.Formatter { value -> + val date = baseDate.plusDays((value - daysInPast).toLong()) + if (value != daysInPast) { + DateUtils.getFormattedDate(date.atStartOfDay(zoneId).toEpochSecond() * 1000L) + } else { + context.getString(R.string.date_today) + } + } + + hourFormatter = NumberPicker.Formatter { value -> + String.format(Locale(context.getString(R.string.language_key)), "%02d", value) + } + minuteFormatter = NumberPicker.Formatter { value -> + String.format(Locale(context.getString(R.string.language_key)), "%02d", value) + } + } + + private fun initializePickers() { + val font = ResourcesCompat.getFont(context, R.font.inter_regular) + + binding.datePicker.apply { + minValue = 0 + maxValue = daysInPast + daysInFuture + wrapSelectorWheel = false + formatter = dateFormatter + typeface = font + setSelectedTypeface(font) + setOnValueChangedListener { _, _, _ -> + changeListener?.onDateTimeChanged(getSelectedDateTime()) + } + } + + binding.hourPicker.apply { + minValue = 0 + maxValue = 23 + wrapSelectorWheel = true + formatter = hourFormatter + typeface = font + setSelectedTypeface(font) + setOnValueChangedListener { _, _, _ -> + changeListener?.onDateTimeChanged(getSelectedDateTime()) + } + } + + binding.minutePicker.apply { + minValue = 0 + maxValue = 59 + wrapSelectorWheel = true + formatter = minuteFormatter + typeface = font + setSelectedTypeface(font) + setOnValueChangedListener { _, _, _ -> + changeListener?.onDateTimeChanged(getSelectedDateTime()) + } + } + } + + fun setDateTime(unixTimestamp: Long) { + setDateTime( + Instant.ofEpochMilli(unixTimestamp) + .atZone(zoneId) + .toLocalDateTime() + ) + } + + fun setDateTime(previousDateTime: LocalDateTime) { + this.previousDateTime = previousDateTime + updatePickerValues() + } + + fun setOnDateTimeChangedListener(changeListener: OnDateTimeChangedListener) { + this.changeListener = changeListener + } + + fun getSelectedDateTime(): LocalDateTime { + val dateValue = binding.datePicker.value - daysInPast + val hourValue = binding.hourPicker.value + val minuteValue = binding.minutePicker.value + return now.plusDays(dateValue.toLong()).withHour(hourValue).withMinute(minuteValue) + } + + fun getSelectedUnixTimestamp(): Long { + return getSelectedDateTime().atZone(zoneId).toEpochSecond() * 1000L + } + + private fun updatePickerValues() { + val dayDifference = ChronoUnit.DAYS.between(now.toLocalDate(), previousDateTime.toLocalDate()) + val preSelectedValue = daysInPast + dayDifference + binding.datePicker.value = preSelectedValue.toInt() + binding.hourPicker.value = previousDateTime.hour + val minuteSelectionValue = previousDateTime.minute + binding.minutePicker.value = minuteSelectionValue + } + + fun interface OnDateTimeChangedListener { + fun onDateTimeChanged(newDateTime: LocalDateTime) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/whattodo/WtdPositiveTestFragment.java b/app/src/main/java/ch/admin/bag/dp3t/whattodo/WtdPositiveTestFragment.java index ddf0f5b99..b91f77cb3 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/whattodo/WtdPositiveTestFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/whattodo/WtdPositiveTestFragment.java @@ -10,7 +10,6 @@ package ch.admin.bag.dp3t.whattodo; import android.content.Context; -import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.ImageView; @@ -22,9 +21,7 @@ import androidx.fragment.app.Fragment; import ch.admin.bag.dp3t.R; -import ch.admin.bag.dp3t.inform.InformActivity; import ch.admin.bag.dp3t.networking.models.FaqEntryModel; -import ch.admin.bag.dp3t.networking.models.InfoBoxModel; import ch.admin.bag.dp3t.networking.models.WhatToDoPositiveTestTextsModel; import ch.admin.bag.dp3t.storage.SecureStorage; import ch.admin.bag.dp3t.util.PhoneUtil; @@ -47,21 +44,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat fillContentFromConfigServer(view); - view.findViewById(R.id.wtd_inform_button).setOnClickListener(v -> { - Intent intent = new Intent(getActivity(), InformActivity.class); - startActivity(intent); - }); - view.findViewById(R.id.wtd_inform_faq_button).setOnClickListener(v -> { UrlUtil.openUrl(getContext(), getString(R.string.faq_button_url)); }); - - View oldCallButton = view.findViewById(R.id.wtd_inform_call_infoline_coronavirus); - if (oldCallButton != null) { - oldCallButton.setOnClickListener(v -> { - PhoneUtil.callInfolineCoronavirus(v.getContext()); - }); - } } private void fillContentFromConfigServer(View view) { @@ -71,50 +56,6 @@ private void fillContentFromConfigServer(View view) { secureStorage.getWhatToDoPositiveTestTexts(context.getString(R.string.language_key)); if (textModel != null) { - ((TextView) view.findViewById(R.id.wtd_inform_box_supertitle)).setText(textModel.getEnterCovidcodeBoxSupertitle()); - ((TextView) view.findViewById(R.id.wtd_inform_box_title)).setText(textModel.getEnterCovidcodeBoxTitle()); - ((TextView) view.findViewById(R.id.wtd_inform_box_text)).setText(textModel.getEnterCovidcodeBoxText()); - ((TextView) view.findViewById(R.id.wtd_inform_button)).setText(textModel.getEnterCovidcodeBoxButtonTitle()); - InfoBoxModel infoBox = textModel.getInfoBox(); - - if (infoBox != null) { - view.findViewById(R.id.wtd_inform_infobox).setVisibility(View.VISIBLE); - ((TextView) view.findViewById(R.id.wtd_inform_infobox_title)).setText(infoBox.getTitle()); - ((TextView) view.findViewById(R.id.wtd_inform_infobox_msg)).setText(infoBox.getMsg()); - - if (infoBox.getUrl() != null && infoBox.getUrlTitle() != null) { - ((TextView) view.findViewById(R.id.wtd_inform_infobox_link_text)).setText(infoBox.getUrlTitle()); - view.findViewById(R.id.wtd_inform_infobox_link_layout).setOnClickListener(v -> { - UrlUtil.openUrl(v.getContext(), infoBox.getUrl()); - }); - view.findViewById(R.id.wtd_inform_infobox_link_layout).setVisibility(View.VISIBLE); - ImageView linkIcon = view.findViewById(R.id.wtd_inform_infobox_link_icon); - if (infoBox.getUrl().startsWith("tel://")) { - linkIcon.setImageResource(R.drawable.ic_phone); - } else { - linkIcon.setImageResource(R.drawable.ic_launch); - } - } else { - view.findViewById(R.id.wtd_inform_infobox_link_layout).setVisibility(View.GONE); - } - - if (infoBox.getHearingImpairedInfo() != null) { - ((ImageView) view.findViewById(R.id.wtd_inform_infobox_link_icon)).setImageResource(R.drawable.ic_phone); - view.findViewById(R.id.wtd_inform_infobox_link_hearing_impaired).setOnClickListener(v -> { - requireActivity().getSupportFragmentManager().beginTransaction() - .add(WtdInfolineAccessabilityDialogFragment.newInstance(infoBox.getHearingImpairedInfo()), - WtdInfolineAccessabilityDialogFragment.class.getCanonicalName()) - .commit(); - }); - view.findViewById(R.id.wtd_inform_infobox_link_hearing_impaired).setVisibility(View.VISIBLE); - } else { - ((ImageView) view.findViewById(R.id.wtd_inform_infobox_link_icon)).setImageResource(R.drawable.ic_launch); - view.findViewById(R.id.wtd_inform_infobox_link_hearing_impaired).setVisibility(View.GONE); - } - } else { - view.findViewById(R.id.wtd_inform_infobox).setVisibility(View.GONE); - } - LinearLayout faqLayout = view.findViewById(R.id.wtd_inform_faq_layout); faqLayout.removeAllViews(); diff --git a/app/src/main/res/drawable-nodpi/hidden_event_blur.png b/app/src/main/res/drawable-nodpi/hidden_event_blur.png new file mode 100644 index 000000000..46a85d1df Binary files /dev/null and b/app/src/main/res/drawable-nodpi/hidden_event_blur.png differ diff --git a/app/src/main/res/drawable/bg_button_outlined.xml b/app/src/main/res/drawable/bg_button_outlined.xml deleted file mode 100644 index b22125795..000000000 --- a/app/src/main/res/drawable/bg_button_outlined.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_camera_flash_button.xml b/app/src/main/res/drawable/bg_camera_flash_button.xml new file mode 100644 index 000000000..a11ee9229 --- /dev/null +++ b/app/src/main/res/drawable/bg_camera_flash_button.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_edit_text.xml b/app/src/main/res/drawable/bg_edit_text.xml new file mode 100644 index 000000000..899df8f9e --- /dev/null +++ b/app/src/main/res/drawable/bg_edit_text.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 000000000..00231c6ac --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml index 87f3ebaab..4f714818e 100644 --- a/app/src/main/res/drawable/ic_check_circle.xml +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -1,19 +1,14 @@ - - - + android:width="30dp" + android:height="30dp" + android:viewportWidth="30" + android:viewportHeight="30"> + + diff --git a/app/src/main/res/drawable/ic_check_circle_large.xml b/app/src/main/res/drawable/ic_check_circle_large.xml deleted file mode 100644 index cf5bb8b06..000000000 --- a/app/src/main/res/drawable/ic_check_circle_large.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_check_in.xml b/app/src/main/res/drawable/ic_check_in.xml new file mode 100644 index 000000000..4c60e940d --- /dev/null +++ b/app/src/main/res/drawable/ic_check_in.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..5204eb007 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_illu_checked_in.xml b/app/src/main/res/drawable/ic_illu_checked_in.xml new file mode 100644 index 000000000..83ad70ca7 --- /dev/null +++ b/app/src/main/res/drawable/ic_illu_checked_in.xml @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_illu_checkin_ended.xml b/app/src/main/res/drawable/ic_illu_checkin_ended.xml new file mode 100644 index 000000000..2b762a497 --- /dev/null +++ b/app/src/main/res/drawable/ic_illu_checkin_ended.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_illu_covidcode.xml b/app/src/main/res/drawable/ic_illu_covidcode.xml new file mode 100644 index 000000000..12c9e4087 --- /dev/null +++ b/app/src/main/res/drawable/ic_illu_covidcode.xml @@ -0,0 +1,41 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_illu_veranstaltungen.xml b/app/src/main/res/drawable/ic_illu_veranstaltungen.xml new file mode 100644 index 000000000..ba94bcc5c --- /dev/null +++ b/app/src/main/res/drawable/ic_illu_veranstaltungen.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_light_off.xml b/app/src/main/res/drawable/ic_light_off.xml new file mode 100644 index 000000000..46eee91e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_light_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_light_on.xml b/app/src/main/res/drawable/ic_light_on.xml new file mode 100644 index 000000000..ea685d0da --- /dev/null +++ b/app/src/main/res/drawable/ic_light_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_location_pin.xml b/app/src/main/res/drawable/ic_location_pin.xml new file mode 100644 index 000000000..5069074de --- /dev/null +++ b/app/src/main/res/drawable/ic_location_pin.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_off.xml b/app/src/main/res/drawable/ic_notification_off.xml new file mode 100644 index 000000000..f043d43f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_print.xml b/app/src/main/res/drawable/ic_print.xml new file mode 100644 index 000000000..79355cf2f --- /dev/null +++ b/app/src/main/res/drawable/ic_print.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 000000000..77567b22b --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_qr_small.xml b/app/src/main/res/drawable/ic_qr_small.xml new file mode 100644 index 000000000..74b0e00f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_small.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..35a3e5a67 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_smartphone.xml b/app/src/main/res/drawable/ic_smartphone.xml new file mode 100644 index 000000000..576481770 --- /dev/null +++ b/app/src/main/res/drawable/ic_smartphone.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_snooze.xml b/app/src/main/res/drawable/ic_snooze.xml new file mode 100644 index 000000000..a86579ee0 --- /dev/null +++ b/app/src/main/res/drawable/ic_snooze.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_stopwatch.xml b/app/src/main/res/drawable/ic_stopwatch.xml new file mode 100644 index 000000000..893353f26 --- /dev/null +++ b/app/src/main/res/drawable/ic_stopwatch.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tea.xml b/app/src/main/res/drawable/ic_tea.xml new file mode 100644 index 000000000..df643ae3e --- /dev/null +++ b/app/src/main/res/drawable/ic_tea.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 000000000..eb696841d --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning_round.xml b/app/src/main/res/drawable/ic_warning_round.xml new file mode 100644 index 000000000..4db6c0736 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_round.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ill_checkins.xml b/app/src/main/res/drawable/ill_checkins.xml new file mode 100644 index 000000000..2d3075b92 --- /dev/null +++ b/app/src/main/res/drawable/ill_checkins.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ill_no_message.xml b/app/src/main/res/drawable/ill_no_message.xml index 6d2cddf12..9845b7b9c 100644 --- a/app/src/main/res/drawable/ill_no_message.xml +++ b/app/src/main/res/drawable/ill_no_message.xml @@ -1,38 +1,26 @@ - - - + + android:pathData="M62.633,20.558c-0.093,-0.093 -0.189,-0.186 -0.287,-0.28 0.753,-0.765 1.534,-1.552 1.527,-2.538 -0.008,-1.196 -1.194,-2.116 -1.637,-3.246 -0.602,-1.538 0.234,-3.19 0.578,-4.791 0.539,-2.63 -0.258,-5.34 -2.175,-7.39 -0.348,-0.37 -0.752,-0.726 -1.271,-0.873 -0.79,-0.222 -1.627,0.087 -2.449,0.178 -2.712,0.3 -5.178,-1.793 -7.903,-1.606 -0.18,0.006 -0.358,0.041 -0.524,0.105 -0.353,0.15 -0.545,0.485 -0.762,0.771 -0.846,1.118 -2.267,1.677 -3.68,2.243 -0.178,0.047 -0.347,0.117 -0.503,0.207 -0.424,0.178 -0.842,0.362 -1.237,0.572 -2.67,1.416 -2.282,3.397 -0.708,4.62 -0.312,1.374 -1.872,2.583 -1.605,3.996 0.212,1.107 1.499,1.86 1.747,2.961 0.144,0.64 -0.083,1.25 -0.398,1.855 -2.987,0.62 -6.362,1.761 -6.626,3.774 -0.458,3.502 -1.837,13.176 -1.837,13.176s-1.6,7.748 4.469,4.078l-0.078,0.207c-1.095,2.968 -1.842,6.03 -2.23,9.136 -0.06,0.463 -0.185,0.826 -0.41,0.945 -0.08,0.043 0.08,0.112 0.431,0.197 -0.039,0.13 -0.083,0.26 -0.12,0.392 -0.667,2.265 -1.093,4.582 -1.273,6.919 -0.171,2.07 -0.49,4.128 -0.954,6.162 -1.439,6.482 0,14.798 0,14.798l3.138,13.238 0.536,2.263c0.355,0.35 0.788,0.63 1.272,0.821 0.21,0.811 0.356,1.635 0.436,2.464 0.008,0.093 0.016,0.186 0.022,0.28 -0.382,0.042 -0.724,0.233 -0.936,0.521 -1.118,1.409 0.156,2.537 -4.153,5.356 -1.116,0.728 -1.652,1.39 -1.79,1.956 -0.146,0.606 0.172,1.096 0.741,1.427 0.098,0.056 0.199,0.107 0.303,0.151l0.156,0.065c0.055,0.02 0.112,0.04 0.17,0.057h0.01l0.166,0.047h0.013l0.18,0.042h0.015l0.168,0.033h0.022l0.186,0.027h0.02l0.168,0.019h0.033l0.189,0.012L34.23,105.905l0.168,-0.008h0.045l0.19,-0.017h0.022l0.17,-0.022 0.047,-0.007 0.189,-0.032h0.019l0.171,-0.038 0.049,-0.011c0.062,-0.016 0.123,-0.031 0.185,-0.05h0.011l0.173,-0.054 0.047,-0.015c0.06,-0.021 0.12,-0.042 0.18,-0.066 2.876,-1.128 8.787,-4.086 8.787,-4.086v-0.095,-0.041 -0.036,-0.09 -0.106,-0.068 -0.123c-0.017,-1.215 -0.157,-4.05 -0.781,-3.939 -0.214,0.038 -0.359,0.306 -0.507,0.615l-0.073,-0.126c-0.609,-1.052 -1.49,-2.858 -1.576,-4.623 0.491,-0.319 0.947,-0.68 1.359,-1.079 0,0 -0.322,-1.212 -0.595,-2.49 -0.237,-1.109 -0.438,-2.266 -0.365,-2.724 0.156,-0.987 -1.917,-8.597 -1.917,-8.597s0.214,-1.677 0.645,-4.001c0.836,-4.523 2.481,-11.511 4.936,-13.487 0.058,-0.045 0.114,-0.09 0.172,-0.129 0,0 1.758,0.28 3.196,9.16l0.416,3.954c0.121,1.095 0.33,2.181 0.624,3.25 0.284,1.052 0.588,2.813 0.401,5.197 -0.182,2.331 -0.26,5.075 -0.293,7 -0.025,1.453 -0.025,2.44 -0.025,2.44 0.298,0.226 0.619,0.425 0.96,0.595v1.293c-0.004,0.274 -0.125,0.537 -0.338,0.735 -0.226,0.228 -0.391,0.5 -0.482,0.795 -0.279,0.45 -0.514,1.18 -0.613,2.356 -0.312,3.806 -0.478,3.382 -0.478,3.382 -0.184,0.412 -0.305,0.843 -0.362,1.283 -0.01,0.083 -0.014,0.165 -0.018,0.25v0.207c0,0.02 0.003,0.047 0.008,0.084 0,0.025 0.004,0.05 0.011,0.076 0,0.018 0,0.036 0.01,0.053 0.009,0.017 0.014,0.067 0.023,0.1l0.012,0.044c0.011,0.035 0.022,0.068 0.035,0.102l0.012,0.03c0.013,0.031 0.027,0.061 0.042,0.09l0.014,0.027c0.02,0.033 0.04,0.067 0.063,0.099l0.019,0.026c0.023,0.032 0.049,0.063 0.076,0.093l0.02,0.02c0.03,0.032 0.063,0.061 0.097,0.089l0.01,0.008c0.036,0.028 0.073,0.053 0.112,0.077l0.017,0.011c0.042,0.025 0.086,0.047 0.13,0.067l0.027,0.013c0.051,0.02 0.103,0.039 0.156,0.054l0.027,0.007c0.058,0.018 0.117,0.032 0.178,0.044 0.062,0.01 0.127,0.02 0.196,0.026h0.028c0.069,0 0.14,0.01 0.215,0.01L50.827,103.507c0.087,0 0.179,-0.014 0.274,-0.027 3.355,-0.419 4.954,-0.698 4.793,-2.395 -0.096,-1.019 -0.366,-2.499 -0.566,-3.515 -0.101,-0.516 -0.287,-1.015 -0.552,-1.482l0.023,-0.244c0.058,-0.699 0.055,-1.537 -0.182,-2.087l0.073,-1.234c0.83,-0.189 1.581,-0.592 2.159,-1.158 0,0 0.023,-0.914 0.094,-2.128 0.106,-1.872 0.312,-4.457 0.705,-5.482 0.64,-1.691 0,-7.61 0,-7.61s-2.398,-10.147 -0.156,-14.516c1.973,-3.853 1.956,-7.709 0.057,-11.46 0.038,-0.026 0.072,-0.056 0.102,-0.09 0,0 -0.48,-0.279 -0.312,-3.523 0.121,-2.474 -0.687,-5.031 -1.077,-6.099 0.61,0.235 1.235,0.436 1.872,0.604 3.667,0.98 3.987,-2.69 3.987,-2.69l0.799,-5.497 0.948,-8.186c0.259,-0.643 0.607,-2.25 -1.235,-4.13zM46.389,15.128c0,0.091 -0.01,0.182 -0.022,0.271 -0.03,0.292 -0.098,0.579 -0.204,0.856 0.11,-0.374 0.176,-0.757 0.198,-1.143l0.028,0.016zM56.049,35.135c0.056,0.094 0.11,0.183 0.156,0.28l-0.404,-0.185c0.15,-0.665 0.391,-1.31 0.72,-1.92 0.397,-0.71 0.724,-1.451 0.977,-2.213 -0.011,0.88 0.036,1.76 0.142,2.635 0.326,2.249 -1.591,1.403 -1.591,1.403z" + android:fillType="nonZero"> @@ -41,281 +29,155 @@ + android:fillType="nonZero"/> - + android:fillType="nonZero"/> + android:pathData="M43.995,98.038l-1.404,1.258s-4.991,2.376 -4.367,-0.839c0.17,-0.955 0.206,-1.926 0.107,-2.89 -0.129,-1.345 -0.426,-2.673 -0.887,-3.958l4.679,-0.838c-0.78,2.191 0.504,4.95 1.304,6.354 0.32,0.567 0.568,0.913 0.568,0.913zM54.623,95.496c-0.035,0.43 -0.094,0.86 -0.178,1.284 0,0 -3.9,2.376 -4.211,-0.28 -0.203,-1.73 0.256,-2.515 0.602,-2.845 0.208,-0.197 0.325,-0.457 0.328,-0.728v-3.135l3.431,0.979 -0.156,2.655c0.237,0.552 0.24,1.377 0.184,2.07z" + android:fillColor="#B2858C" + android:fillType="nonZero"/> + android:fillType="nonZero"/> + android:fillType="nonZero"/> + android:fillType="nonZero"/> + android:pathData="M44.307,25.223s-8.267,-7.547 -2.028,-6.988c1.415,0.127 2.372,-0.1 3.007,-0.524 0.724,-0.483 1.028,-1.224 1.112,-1.997 0.172,-1.537 -0.531,-3.215 -0.531,-3.215s8.422,-6.01 6.707,1.538c-0.189,0.767 -0.298,1.548 -0.328,2.332 -0.028,1.602 0.56,2.216 1.385,2.467 1.391,0.426 3.46,-0.17 4.402,1.21 1.716,2.522 -13.726,5.177 -13.726,5.177z" + android:fillColor="#B2858C" + android:fillType="nonZero"/> + android:pathData="M56.785,10.409c0,3.319 -3.003,6.01 -6.707,6.01 -3.704,0 -6.707,-2.691 -6.707,-6.01 0,-0.112 0,-0.224 0.01,-0.334 0.208,-3.246 3.257,-5.755 6.883,-5.665 3.627,0.09 6.516,2.748 6.521,5.999z" + android:fillColor="#B2858C" + android:fillType="nonZero"/> + android:pathData="M46.023,16.977s-10.529,0.769 -10.997,4.403c-0.448,3.474 -1.794,13.067 -1.794,13.067s-1.56,7.687 4.368,4.053l-0.077,0.204c-1.069,2.946 -1.798,5.982 -2.176,9.061 -0.06,0.458 -0.18,0.82 -0.4,0.938 -0.78,0.42 20.9,3.214 22.46,1.397 0,0 -0.468,-0.28 -0.312,-3.494 0.156,-3.214 -1.247,-6.568 -1.247,-6.568s-1.248,-3.355 0.468,-6.569c1.715,-3.214 1.091,-4.193 1.091,-4.193l5.928,-4.053s1.28,-1.966 -1.061,-4.392c-1.42,-1.45 -3.424,-2.335 -5.572,-2.46l-4.086,-0.266s4.48,4.319 -3.943,4.738c-8.423,0.42 -2.65,-5.866 -2.65,-5.866z" + android:fillColor="#5094BF" + android:fillType="nonZero"/> - - + android:fillType="nonZero"/> + android:fillType="nonZero"/> + android:fillType="nonZero"/> + android:pathData="M35.113,35.333c0.285,-0.76 0.93,-1.368 1.763,-1.663 0.533,-0.198 1.184,-0.559 1.513,-1.217 0.614,-1.22 1.706,-2.478 4.514,-1.64 2.808,0.84 -0.78,1.398 -0.78,1.398s6.395,-0.838 3.9,0.839c-2.496,1.677 -5.616,2.655 -5.616,2.655l-1.536,1.458c-0.936,0.89 -2.561,0.886 -3.432,-0.062 -0.39,-0.427 -0.595,-1.003 -0.326,-1.768z" + android:fillColor="#B2858C" + android:fillType="nonZero"/> + android:pathData="M57.409,37.941l-1.092,2.376 -1.404,-0.92 -3.9,-2.574 -2.65,-1.537c-3.744,-2.516 0,-2.935 0,-2.935 -1.405,-0.56 0,-1.118 0,-1.118 4.99,-0.978 4.055,2.655 4.055,2.655l3.161,1.47 1.05,0.487 0.78,2.096z" + android:fillColor="#B2858C" + android:fillType="nonZero"/> + android:pathData="M63.18,23.826l0.301,1.1 -0.925,8.124 -0.78,5.45s-0.312,3.634 -3.9,2.656c-3.587,-0.979 -3.899,-1.957 -3.899,-1.957s5.148,1.538 1.872,-3.913c0,0 1.872,0.838 1.56,-1.398 -0.312,-2.236 0,-6.15 0,-6.15l5.771,-3.912z" + android:fillColor="#5094BF" + android:fillType="nonZero"/> + android:fillType="nonZero"/> - - - - - - - + android:pathData="M81.626,66.863c-0.125,1.223 -2.963,4.568 -2.963,4.568s-2.11,-3.752 -1.983,-4.976c0.064,-0.804 0.608,-1.514 1.422,-1.856 0.814,-0.343 1.77,-0.264 2.502,0.206 0.73,0.47 1.121,1.256 1.02,2.057l0.002,0.001zM85.019,73.977c-0.847,0.966 -5.257,2.393 -5.257,2.393s0.506,-4.176 1.353,-5.142c0.532,-0.664 1.429,-1.012 2.334,-0.907 0.905,0.105 1.673,0.646 2,1.41 0.327,0.763 0.16,1.626 -0.435,2.246h0.005zM83.652,85.544c-1.276,0.45 -5.885,-0.313 -5.885,-0.313s2.79,-3.377 4.065,-3.827c0.829,-0.306 1.777,-0.188 2.482,0.308 0.705,0.497 1.056,1.295 0.919,2.09 -0.138,0.793 -0.742,1.459 -1.58,1.742zM80.419,94.086c-1.173,0.637 -5.86,0.583 -5.86,0.583s2.113,-3.749 3.285,-4.388c0.759,-0.428 1.717,-0.456 2.505,-0.073 0.789,0.383 1.286,1.117 1.3,1.92 0.015,0.804 -0.456,1.552 -1.23,1.958zM74.546,76.178c0.892,0.934 5.364,2.192 5.364,2.192s-0.702,-4.154 -1.592,-5.086c-0.898,-0.913 -2.447,-1.01 -3.478,-0.219 -1.031,0.792 -1.162,2.178 -0.294,3.113zM70.906,86.213c1.162,0.651 5.85,0.653 5.85,0.653s-2.055,-3.772 -3.217,-4.426c-0.753,-0.435 -1.708,-0.472 -2.5,-0.099 -0.793,0.373 -1.3,1.1 -1.327,1.902 -0.027,0.802 0.43,1.554 1.195,1.97h-0.001zM68.86,96.4c1.023,0.818 5.643,1.53 5.643,1.53s-1.315,-4.032 -2.34,-4.85c-0.656,-0.56 -1.602,-0.756 -2.466,-0.51 -0.864,0.247 -1.51,0.896 -1.685,1.693 -0.174,0.798 0.15,1.617 0.847,2.137z" + android:fillColor="#5094BF" + android:fillType="nonZero"/> diff --git a/app/src/main/res/drawable/ill_tracing_beenden.xml b/app/src/main/res/drawable/ill_tracing_beenden.xml index 156850241..3b1871b91 100644 --- a/app/src/main/res/drawable/ill_tracing_beenden.xml +++ b/app/src/main/res/drawable/ill_tracing_beenden.xml @@ -1,26 +1,10 @@ - - - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/illu_diary.xml b/app/src/main/res/drawable/illu_diary.xml new file mode 100644 index 000000000..f7ca670e0 --- /dev/null +++ b/app/src/main/res/drawable/illu_diary.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/illu_events.xml b/app/src/main/res/drawable/illu_events.xml new file mode 100644 index 000000000..f2c95be9a --- /dev/null +++ b/app/src/main/res/drawable/illu_events.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/illu_not_thank_you.xml b/app/src/main/res/drawable/illu_not_thank_you.xml new file mode 100644 index 000000000..22a6cae2c --- /dev/null +++ b/app/src/main/res/drawable/illu_not_thank_you.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/pdf_icon.xml b/app/src/main/res/drawable/pdf_icon.xml new file mode 100644 index 000000000..02e6ce01c --- /dev/null +++ b/app/src/main/res/drawable/pdf_icon.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/qr_scanner_bottom_left.xml b/app/src/main/res/drawable/qr_scanner_bottom_left.xml new file mode 100644 index 000000000..26e8ab204 --- /dev/null +++ b/app/src/main/res/drawable/qr_scanner_bottom_left.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/qr_scanner_bottom_right.xml b/app/src/main/res/drawable/qr_scanner_bottom_right.xml new file mode 100644 index 000000000..628e53eaa --- /dev/null +++ b/app/src/main/res/drawable/qr_scanner_bottom_right.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/qr_scanner_top_left.xml b/app/src/main/res/drawable/qr_scanner_top_left.xml new file mode 100644 index 000000000..710fb8a69 --- /dev/null +++ b/app/src/main/res/drawable/qr_scanner_top_left.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/qr_scanner_top_right.xml b/app/src/main/res/drawable/qr_scanner_top_right.xml new file mode 100644 index 000000000..e07149dcc --- /dev/null +++ b/app/src/main/res/drawable/qr_scanner_top_right.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/reminder_toggle_background_color.xml b/app/src/main/res/drawable/reminder_toggle_background_color.xml new file mode 100644 index 000000000..1df3bbb8d --- /dev/null +++ b/app/src/main/res/drawable/reminder_toggle_background_color.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/reminder_toggle_stroke_color.xml b/app/src/main/res/drawable/reminder_toggle_stroke_color.xml new file mode 100644 index 000000000..18533db95 --- /dev/null +++ b/app/src/main/res/drawable/reminder_toggle_stroke_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/reminder_toggle_text_color.xml b/app/src/main/res/drawable/reminder_toggle_text_color.xml new file mode 100644 index 000000000..b353a5a2a --- /dev/null +++ b/app/src/main/res/drawable/reminder_toggle_text_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ripple_rounded.xml b/app/src/main/res/drawable/ripple_rounded.xml new file mode 100644 index 000000000..7c2cbd288 --- /dev/null +++ b/app/src/main/res/drawable/ripple_rounded.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/card_checkin.xml b/app/src/main/res/layout/card_checkin.xml new file mode 100644 index 000000000..fbce02ff8 --- /dev/null +++ b/app/src/main/res/layout/card_checkin.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + +