diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f85ac0b82..324f92c4c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -39,7 +39,7 @@ jobs: with: java-version: 1.8 - name: Run tests - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@5de26e4bd23bf523e8a4b7f077df8bfb8e52b50e with: api-level: 29 script: ./gradlew connectedDevDebugAndroidTest diff --git a/.github/workflows/appcenter_abnahme.yml b/.github/workflows/appcenter_abnahme.yml index 83fa2e9a7..ea6ee6bd2 100644 --- a/.github/workflows/appcenter_abnahme.yml +++ b/.github/workflows/appcenter_abnahme.yml @@ -18,7 +18,7 @@ jobs: with: gradle-cmd: assembleAbnahmeRelease -PkeystorePassword=${{secrets.KEYSTORE_PASSWORD}} -PkeyAliasPassword=${{secrets.KEY_ALIAS_PASSWORD}} - name: upload artefact to App Center - uses: wzieba/AppCenter-Github-Action@v1.0.0 + uses: wzieba/AppCenter-Github-Action@8db6b765c4d7ce337bd783ea986f17ce0c9a9e85 with: appName: ${{secrets.APPCENTER_ORGANIZATION}}/${{secrets.APPCENTER_APP_ABNAHME}} token: ${{secrets.APPCENTER_API_TOKEN}} diff --git a/.github/workflows/appcenter_dev.yml b/.github/workflows/appcenter_dev.yml index 162892a6a..a6d89f1fa 100644 --- a/.github/workflows/appcenter_dev.yml +++ b/.github/workflows/appcenter_dev.yml @@ -18,7 +18,7 @@ jobs: with: gradle-cmd: assembleDevRelease -PkeystorePassword=${{secrets.KEYSTORE_PASSWORD}} -PkeyAliasPassword=${{secrets.KEY_ALIAS_PASSWORD}} - name: upload artefact to App Center - uses: wzieba/AppCenter-Github-Action@v1.0.0 + uses: wzieba/AppCenter-Github-Action@8db6b765c4d7ce337bd783ea986f17ce0c9a9e85 with: appName: ${{secrets.APPCENTER_ORGANIZATION}}/${{secrets.APPCENTER_APP_DEV}} token: ${{secrets.APPCENTER_API_TOKEN}} diff --git a/.github/workflows/appcenter_prod.yml b/.github/workflows/appcenter_prod.yml index 6e51b29f6..c3d24ac1b 100644 --- a/.github/workflows/appcenter_prod.yml +++ b/.github/workflows/appcenter_prod.yml @@ -18,7 +18,7 @@ jobs: with: gradle-cmd: assembleProdRelease -PkeystorePassword=${{secrets.KEYSTORE_PASSWORD}} -PkeyAliasPassword=${{secrets.KEY_ALIAS_PASSWORD}} - name: upload artefact to App Center - uses: wzieba/AppCenter-Github-Action@v1.0.0 + uses: wzieba/AppCenter-Github-Action@8db6b765c4d7ce337bd783ea986f17ce0c9a9e85 with: appName: ${{secrets.APPCENTER_ORGANIZATION}}/${{secrets.APPCENTER_APP}} token: ${{secrets.APPCENTER_API_TOKEN}} diff --git a/README.md b/README.md index c7a986f34..6a1b27eec 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ [![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. +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 app design, UX and implementation was done by [Ubique](https://www.ubique.ch/?app=github). + +## Contact tracing + +The SwissCovid 2.0 app uses two types of contact tracing to prevent the spread of COVID-19. 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. diff --git a/app/build.gradle b/app/build.gradle index d9819ed34..2b50b20ed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,8 +24,8 @@ android { applicationId "ch.admin.bag.dp3t" minSdkVersion 23 targetSdkVersion 30 - versionCode 20010 - versionName "2.0.1" + versionCode 21000 + versionName "2.1.0" resConfigs "en", "fr", "de", "it", "pt", "es", "sq", "bs", "hr", "sr", "rm", "tr", "ti" buildConfigField "long", "BUILD_TIME", readPropertyWithDefault('buildTimestamp', System.currentTimeMillis()) + '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 287fc2e41..41715359e 100644 --- a/app/src/main/assets/disclaimer/de/terms_of_use.html +++ b/app/src/main/assets/disclaimer/de/terms_of_use.html @@ -17,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), 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 vom 30. Juni 2021 über ein System zur Benachrichtigung über eine mögliche Ansteckung mit dem Coronavirus Sars-CoV-2 an Veranstaltungen (VBV; SR 818.102.4). + 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 vom 30. Juni 2021 über ein System zur Benachrichtigung über eine mögliche Ansteckung mit dem Coronavirus Sars-CoV-2 an Veranstaltungen (VBV; SR 818.102.4).

1.3 @@ -65,11 +65,13 @@ 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. + Warnsystem für Veranstaltungen: Die Organisatorin oder der Organisator einer Veranstaltung generiert in seiner App oder über die Internetseite + qr.swisscovid.ch + 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 - 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 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 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 6acec624d..1410ce32e 100644 --- a/app/src/main/assets/disclaimer/en/terms_of_use.html +++ b/app/src/main/assets/disclaimer/en/terms_of_use.html @@ -65,14 +65,13 @@ 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. + Warning system for events: The organiser of an event generates a QR code in their app or on the website + qr.swisscovid.ch + . People attending the event can use their app to scan the displayed QR code and are thus checked in to the event.

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. 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. + 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. 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 or participated in the same event at the same time are notified by their own apps. 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 4e671b083..ffe7e4348 100644 --- a/app/src/main/assets/disclaimer/fr/terms_of_use.html +++ b/app/src/main/assets/disclaimer/fr/terms_of_use.html @@ -28,7 +28,7 @@ 818.102 -) et l’ordonnance du 30 juin 2021 sur un système visant à informer d’une infection possible au coronavirus SARS-CoV-2 lors de manifestations (OSIM ; RS 818.102.4). +) et l’ordonnance du 30 juin 2021 sur un système visant à informer d’une infection possible au coronavirus SARS-CoV-2 lors de manifestations (OSIM; RS 818.102.4).

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. @@ -75,17 +75,16 @@ À 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. + Système d’alerte pour les manifestations : l’organisateur d’une manifestation génère un code QR dans son application ou sur le site Internet + qr.swisscovid.ch + 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. 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.

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 43a0f0f61..da43e2fe3 100644 --- a/app/src/main/assets/disclaimer/it/terms_of_use.html +++ b/app/src/main/assets/disclaimer/it/terms_of_use.html @@ -65,14 +65,13 @@ 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. + Sistema di allerta per le manifestazioni: l’organizzatore di una manifestazione genera nella propria app o tramite la pagina Internet + qr.swisscovid.ch + un codice QR che i visitatori della manifestazione possono scansionare per effettuare il check-in alla manifestazione.

3.4 - 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. + 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. 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 infetto durante il periodo di infettività o che hanno partecipato nello stesso momento alla medesima manifestazione vengono informati dalla loro app. diff --git a/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt b/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt index d9551cd2e..1fa1ef840 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/MainActivity.kt @@ -20,8 +20,9 @@ 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.AlreadyCheckedInErrorDialog import ch.admin.bag.dp3t.checkin.checkinflow.CheckInFragment -import ch.admin.bag.dp3t.checkin.checkinflow.CheckOutFragment +import ch.admin.bag.dp3t.checkin.checkout.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 @@ -47,10 +48,8 @@ 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" @@ -129,7 +128,6 @@ class MainActivity : FragmentActivity() { } private fun onOnboardingFinished(onboardingType: OnboardingType, activityResult: ActivityResult, qrCodeUrl: String? = null) { - if (activityResult.resultCode == RESULT_OK) { secureStorage.lastShownUpdateBoardingVersion = UPDATE_BOARDING_VERSION secureStorage.onboardingCompleted = true @@ -224,7 +222,9 @@ class MainActivity : FragmentActivity() { if (crowdNotifierViewModel.checkInState.venueInfo == venueInfo) { showCheckOutFragment() } else { - ErrorDialog(this, CrowdNotifierErrorState.ALREADY_CHECKED_IN).show() + AlreadyCheckedInErrorDialog(this) + .setOnCheckoutListener { showCheckOutFragment() } + .show() } } else { crowdNotifierViewModel.checkInState = CheckInState( @@ -242,7 +242,9 @@ class MainActivity : FragmentActivity() { try { val venueInfo = CrowdNotifier.getVenueInfo(qrCodeUrl, BuildConfig.ENTRY_QR_CODE_HOST) if (crowdNotifierViewModel.isCheckedIn.value == true) { - ErrorDialog(this, CrowdNotifierErrorState.ALREADY_CHECKED_IN).show() + AlreadyCheckedInErrorDialog(this) + .setOnCheckoutListener { showCheckOutFragment() } + .show() } else { crowdNotifierViewModel.performCheckinAndSetReminders(venueInfo, System.currentTimeMillis(), 0) } 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 index d69868b98..5db53f079 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/checkin/CheckinOverviewFragment.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/CheckinOverviewFragment.kt @@ -11,7 +11,7 @@ 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.checkout.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 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 index 6c0456e0b..0adf02b7a 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/checkin/CrowdNotifierViewModel.java +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/CrowdNotifierViewModel.java @@ -44,6 +44,8 @@ public class CrowdNotifierViewModel extends AndroidViewModel { private final MutableLiveData isCheckedIn = new MutableLiveData<>(false); private CheckInState checkInState; + private boolean isResolvingCheckoutConflicts = false; + private SecureStorage storage; private final Handler handler = new Handler(Looper.getMainLooper()); private Runnable timeUpdateRunnable; @@ -138,7 +140,6 @@ public void refreshTraceKeys() { } private void refreshTraceKeyLoadingError() { - if (storage.getLastSuccessfulCheckinDownload() <= System.currentTimeMillis() - MAX_DURATION_WITHOUT_SUCCESSFUL_DOWNLOAD) { hasTraceKeyDownloadError.setValue(true); } else { @@ -215,6 +216,14 @@ public void performCheckinAndSetReminders(VenueInfo venueInfo, long checkinTime, CrowdNotifierReminderHelper.setReminder(currentTime + selectedReminderDelay, getApplication()); } + public boolean isResolvingCheckoutConflicts() { + return isResolvingCheckoutConflicts; + } + + public void setResolvingCheckoutConflicts(boolean resolvingCheckoutConflicts) { + isResolvingCheckoutConflicts = resolvingCheckoutConflicts; + } + @Override public void onCleared() { super.onCleared(); 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 index 183083e98..5eed5a9cb 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/checkin/EditCheckinBaseFragment.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/EditCheckinBaseFragment.kt @@ -8,7 +8,9 @@ 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.checkout.checkForOverlap 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 ch.admin.bag.dp3t.extensions.getSwissCovidLocationData @@ -44,12 +46,6 @@ abstract class EditCheckinBaseFragment : Fragment() { 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) { @@ -59,16 +55,21 @@ abstract class EditCheckinBaseFragment : Fragment() { return } + val overlappingCheckins = DiaryStorage.getInstance(context).checkForOverlap(checkinInfo) + if (overlappingCheckins.isNotEmpty()) { + handleOverlap(overlappingCheckins) + 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 } + open fun handleOverlap(overlappingCheckins: Collection) { + showSavingNotPossibleDialog(getString(R.string.checkout_overlapping_alert_description), requireContext()) } + abstract fun saveEntry() + private fun showSavingNotPossibleDialog(message: String, context: Context) { AlertDialog.Builder(context, R.style.NextStep_AlertDialogStyle) .setTitle(R.string.checkout_overlapping_alert_title) diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/AlreadyCheckedInErrorDialog.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/AlreadyCheckedInErrorDialog.kt new file mode 100644 index 000000000..1b5e3d426 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/AlreadyCheckedInErrorDialog.kt @@ -0,0 +1,40 @@ +package ch.admin.bag.dp3t.checkin.checkinflow + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import ch.admin.bag.dp3t.R + +class AlreadyCheckedInErrorDialog(context: Context) : AlertDialog(context) { + + private var onCheckoutListener: (() -> Unit)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.dialog_already_checkedin) + + val closeButton = findViewById(R.id.already_checkedin_close_button) + closeButton?.setOnClickListener { dismiss() } + + val checkoutButton = findViewById(R.id.already_checkedin_checkout_button) + checkoutButton?.setOnClickListener { + dismiss() + onCheckoutListener?.invoke() + } + + val cancelButton = findViewById(R.id.already_checkedin_cancel_button) + cancelButton?.setOnClickListener { dismiss() } + + window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + window?.setBackgroundDrawableResource(R.drawable.dialog_background) + } + + fun setOnCheckoutListener(onCheckoutListener: () -> Unit): AlreadyCheckedInErrorDialog { + this.onCheckoutListener = onCheckoutListener + return this + } + +} \ No newline at end of file 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 index 63c55060f..320995dc7 100644 --- 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 @@ -98,8 +98,10 @@ class QrCodeScannerFragment : Fragment(), QrCodeAnalyzer.Listener { camera.cameraInfo.torchState.observe(viewLifecycleOwner, { v: Int -> if (v == TorchState.ON) { flashButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_light_on)) + flashButton.contentDescription = getString(R.string.accessibility_camera_light_on) } else { flashButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_light_off)) + flashButton.contentDescription = getString(R.string.accessibility_camera_light_off) } }) flashButton.setOnClickListener { camera.cameraControl.enableTorch(camera.cameraInfo.torchState.value == TorchState.OFF) } diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutConflictDialogFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutConflictDialogFragment.kt new file mode 100644 index 000000000..0c256d1f2 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutConflictDialogFragment.kt @@ -0,0 +1,99 @@ +package ch.admin.bag.dp3t.checkin.checkout + +import android.content.DialogInterface +import android.os.Bundle +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.text.bold +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.checkin.CrowdNotifierViewModel +import ch.admin.bag.dp3t.checkin.checkout.CheckoutConflictRecyclerViewAdapter.ConflictingVenueVisitItem +import ch.admin.bag.dp3t.checkin.diary.EditDiaryEntryFragment +import ch.admin.bag.dp3t.checkin.models.CheckInState +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage +import ch.admin.bag.dp3t.databinding.DialogFragmentCheckoutConflictBinding +import ch.admin.bag.dp3t.extensions.replace +import ch.admin.bag.dp3t.extensions.showFragment +import org.crowdnotifier.android.sdk.model.VenueInfo +import java.util.regex.Pattern + +class CheckOutConflictDialogFragment : DialogFragment() { + + companion object { + val TAG = CheckOutConflictDialogFragment::class.java.canonicalName + + private const val ARG_CHECKIN_TIME = "CHECKIN_TIME" + private const val ARG_CHECKOUT_TIME = "CHECKOUT_TIME" + + fun newInstance(checkinTime: Long, checkoutTime: Long): CheckOutConflictDialogFragment { + val fragment = CheckOutConflictDialogFragment() + fragment.arguments = Bundle().apply { + putLong(ARG_CHECKIN_TIME, checkinTime) + putLong(ARG_CHECKOUT_TIME, checkoutTime) + } + return fragment + } + } + + private val viewModel: CrowdNotifierViewModel by activityViewModels() + + private lateinit var venueInfo: VenueInfo + private lateinit var checkInState: CheckInState + + private lateinit var diaryStorage: DiaryStorage + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + diaryStorage = DiaryStorage.getInstance(context) + + checkInState = viewModel.checkInState?.copy( + checkInTime = requireArguments().getLong(ARG_CHECKIN_TIME), + checkOutTime = requireArguments().getLong(ARG_CHECKOUT_TIME) + ) ?: return + venueInfo = checkInState.venueInfo + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return DialogFragmentCheckoutConflictBinding.inflate(inflater, container, false).apply { + checkoutConflictText.text = + SpannableString(getText(R.string.checkin_overlap_popup_text)).replace(Pattern.compile("\\{CHECKIN\\}")) { _, _ -> + SpannableStringBuilder().bold { append(venueInfo.title) } + } + + val conflictingItems = diaryStorage.checkForOverlap(checkInState) + .sortedBy { it.checkInTime } + .map { diaryEntry -> + ConflictingVenueVisitItem(diaryEntry) { + viewModel.isResolvingCheckoutConflicts = true + dismiss() + showFragment(EditDiaryEntryFragment.newInstance(diaryEntry.id), modalAnimation = true) + } + } + + checkoutConflictList.adapter = CheckoutConflictRecyclerViewAdapter().apply { + setData(conflictingItems) + } + + checkoutConflictBackButton.setOnClickListener { + viewModel.isResolvingCheckoutConflicts = false + dismiss() + } + }.root + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + viewModel.isResolvingCheckoutConflicts = false + } + + override fun onResume() { + requireDialog().window!!.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + super.onResume() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutConflictResolvedDialogFragment.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutConflictResolvedDialogFragment.kt new file mode 100644 index 000000000..057fdb880 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutConflictResolvedDialogFragment.kt @@ -0,0 +1,82 @@ +package ch.admin.bag.dp3t.checkin.checkout + +import android.os.Bundle +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.text.bold +import androidx.fragment.app.DialogFragment +import ch.admin.bag.dp3t.R +import ch.admin.bag.dp3t.extensions.replace +import ch.admin.bag.dp3t.util.StringUtil.getHourMinuteTimeString +import java.util.regex.Pattern + +class CheckOutConflictResolvedDialogFragment : DialogFragment() { + + companion object { + val TAG = CheckOutConflictResolvedDialogFragment::class.java.canonicalName + + private const val ARG_LOCATION = "LOCATION" + private const val ARG_CHECKIN_TIME = "CHECKIN_TIME" + private const val ARG_CHECKOUT_TIME = "CHECKOUT_TIME" + + fun newInstance(location: String, checkinTime: Long, checkoutTime: Long): CheckOutConflictResolvedDialogFragment { + val fragment = CheckOutConflictResolvedDialogFragment() + fragment.arguments = Bundle().apply { + putString(ARG_LOCATION, location) + putLong(ARG_CHECKIN_TIME, checkinTime) + putLong(ARG_CHECKOUT_TIME, checkoutTime) + } + return fragment + } + } + + private var onCheckoutListener: (() -> Unit)? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_fragment_checkout_conflict_resolved, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val closeButton = view.findViewById(R.id.checkout_resolved_close_button) + closeButton.setOnClickListener { dismiss() } + + val location = requireArguments().getString(ARG_LOCATION) + val checkinTime = requireArguments().getLong(ARG_CHECKIN_TIME) + val checkoutTime = requireArguments().getLong(ARG_CHECKOUT_TIME) + + val checkoutResolvedText = view.findViewById(R.id.checkout_resolved_text) + checkoutResolvedText.text = + SpannableString(getText(R.string.checkin_overlap_popup_success_text)).replace(Pattern.compile("\\{CHECKIN\\}")) { _, _ -> + SpannableStringBuilder().bold { append(location) } + } + + val checkoutResolvedLocation = view.findViewById(R.id.checkout_resolved_location) + checkoutResolvedLocation.text = location + + val checkoutResolvedTime = view.findViewById(R.id.checkout_resolved_time) + val start = getHourMinuteTimeString(checkinTime, ":") + val end = getHourMinuteTimeString(checkoutTime, ":") + checkoutResolvedTime.text = "$start – $end" + + val submitButton = view.findViewById(R.id.checkout_resolved_submit_button) + submitButton.setOnClickListener { + dismiss() + onCheckoutListener?.invoke() + } + } + + override fun onResume() { + requireDialog().window!!.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + super.onResume() + } + + fun setOnCheckoutListener(onCheckoutListener: () -> Unit): CheckOutConflictResolvedDialogFragment { + this.onCheckoutListener = onCheckoutListener + return this + } + +} \ 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/checkout/CheckOutFragment.kt similarity index 70% rename from app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckOutFragment.kt rename to app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutFragment.kt index a2e9828f4..08d7b4a18 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkinflow/CheckOutFragment.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckOutFragment.kt @@ -1,4 +1,4 @@ -package ch.admin.bag.dp3t.checkin.checkinflow +package ch.admin.bag.dp3t.checkin.checkout import android.os.Bundle import android.view.View @@ -9,9 +9,9 @@ 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.CheckinInfo 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 @@ -35,6 +35,11 @@ class CheckOutFragment : EditCheckinBaseFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + viewModel.isResolvingCheckoutConflicts = false + } + checkIfAutoCheckoutHappened() checkInState = viewModel.checkInState?.copy(checkOutTime = System.currentTimeMillis()) ?: return venueInfo = checkInState.venueInfo @@ -58,6 +63,30 @@ class CheckOutFragment : EditCheckinBaseFragment() { checkoutPrimaryButton.setText(R.string.checkout_button_title) checkoutPrimaryButton.setOnClickListener { performSave() } } + + if (viewModel.isResolvingCheckoutConflicts) { + val overlappingCheckins = DiaryStorage.getInstance(context).checkForOverlap(checkinInfo) + if (overlappingCheckins.isNotEmpty()) { + showConflictResolutionDialog() + } else { + showConflictResolutionCompletedDialog() + viewModel.isResolvingCheckoutConflicts = false + } + } + } + + override fun handleOverlap(overlappingCheckins: Collection) { + showConflictResolutionDialog() + } + + private fun showConflictResolutionDialog() { + CheckOutConflictDialogFragment.newInstance(checkInState.checkInTime, checkInState.checkOutTime).show(parentFragmentManager, CheckOutConflictDialogFragment.TAG) + } + + private fun showConflictResolutionCompletedDialog() { + CheckOutConflictResolvedDialogFragment.newInstance(venueInfo.title, checkInState.checkInTime, checkInState.checkOutTime) + .setOnCheckoutListener { performSave() } + .show(parentFragmentManager, CheckOutConflictResolvedDialogFragment.TAG) } override fun saveEntry() { diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckoutConflictRecyclerViewAdapter.java b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckoutConflictRecyclerViewAdapter.java new file mode 100644 index 000000000..09762af66 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckoutConflictRecyclerViewAdapter.java @@ -0,0 +1,97 @@ +package ch.admin.bag.dp3t.checkin.checkout; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +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.models.DiaryEntry; +import ch.admin.bag.dp3t.util.StringUtil; + +public class CheckoutConflictRecyclerViewAdapter + extends RecyclerView.Adapter { + + private final List diaryItems = new ArrayList<>(); + + private ConflictingVenueVisitItem getItem(int position) { + return diaryItems.get(position); + } + + @NonNull + @Override + public ConflictingVenueVisitViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ConflictingVenueVisitViewHolder( + LayoutInflater.from(parent.getContext()).inflate(R.layout.item_checkout_conflicting_venue_visit, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ConflictingVenueVisitViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + @Override + public int getItemCount() { + return diaryItems.size(); + } + + public void setData(List items) { + diaryItems.clear(); + diaryItems.addAll(items); + notifyDataSetChanged(); + } + + + public static class ConflictingVenueVisitViewHolder extends RecyclerView.ViewHolder { + + private final TextView timeTextView; + private final TextView nameTextView; + private final View nameEditButton; + + public ConflictingVenueVisitViewHolder(View itemView) { + super(itemView); + this.timeTextView = itemView.findViewById(R.id.item_conflicting_entry_time); + this.nameTextView = itemView.findViewById(R.id.item_conflicting_entry_name); + this.nameEditButton = itemView.findViewById(R.id.item_conflicting_entry_edit); + } + + public void bind(ConflictingVenueVisitItem 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); + nameEditButton.setOnClickListener(item.getOnClickListener()); + } + + } + + + public static class ConflictingVenueVisitItem { + + private final DiaryEntry diaryEntry; + private final View.OnClickListener onClickListener; + + public ConflictingVenueVisitItem(DiaryEntry diaryEntry, View.OnClickListener onClickListener) { + this.diaryEntry = diaryEntry; + this.onClickListener = onClickListener; + } + + public DiaryEntry getDiaryEntry() { + return diaryEntry; + } + + public View.OnClickListener getOnClickListener() { + return onClickListener; + } + + } + +} diff --git a/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckoutUtils.kt b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckoutUtils.kt new file mode 100644 index 000000000..534fb0429 --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/checkin/checkout/CheckoutUtils.kt @@ -0,0 +1,10 @@ +package ch.admin.bag.dp3t.checkin.checkout + +import ch.admin.bag.dp3t.checkin.models.CheckinInfo +import ch.admin.bag.dp3t.checkin.models.DiaryEntry +import ch.admin.bag.dp3t.checkin.storage.DiaryStorage + +fun DiaryStorage.checkForOverlap(diaryEntry: CheckinInfo): Collection { + val otherCheckins = this.entries.filter { it.id != diaryEntry.id } + return otherCheckins.filter { it.checkOutTime > diaryEntry.checkInTime && diaryEntry.checkOutTime > it.checkInTime } +} 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 index 1bb6fc321..f1367240b 100644 --- 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 @@ -9,7 +9,7 @@ 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.checkout.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 @@ -68,6 +68,7 @@ class DiaryFragment : Fragment() { } val isEmpty = diaryEntries.isEmpty() checkinDiaryEmptyView.isVisible = isEmpty + checkinDiaryRecyclerView.isVisible = !isEmpty var daysAgoString = "" for (diaryEntry in diaryEntries) { val newDaysAgoString: String = DateUtils.getFormattedWeekdayWithDate(diaryEntry.checkInTime, requireContext()) diff --git a/app/src/main/java/ch/admin/bag/dp3t/contacts/ContactsFragment.java b/app/src/main/java/ch/admin/bag/dp3t/contacts/ContactsFragment.java index 1d7697188..fed7979ff 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/contacts/ContactsFragment.java +++ b/app/src/main/java/ch/admin/bag/dp3t/contacts/ContactsFragment.java @@ -144,7 +144,10 @@ private void setupHistoryCard(View view) { break; } } - if (timeSync == null) lastSyncDate.setText("-"); + if (timeSync == null) { + lastSyncDate.setText("\u2013"); + lastSyncDate.setContentDescription(getString(R.string.synchronizations_view_empty_list)); + } historyCardLoadingView.animate() .alpha(0f) .setDuration(getResources().getInteger(android.R.integer.config_shortAnimTime)) diff --git a/app/src/main/java/ch/admin/bag/dp3t/extensions/SpannableExtensions.kt b/app/src/main/java/ch/admin/bag/dp3t/extensions/SpannableExtensions.kt new file mode 100644 index 000000000..79572825d --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/extensions/SpannableExtensions.kt @@ -0,0 +1,30 @@ +package ch.admin.bag.dp3t.extensions + +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import java.util.regex.MatchResult +import java.util.regex.Pattern + +/** + * Search and replace in a spannable text using regular expressions, while keeping spans and allowing to add and remove spans. + * @param pattern regular expression to match. + * @param callback to replace the matched sequence with a new spanned value. + */ +fun Spannable.replace( + pattern: Pattern, + callback: (MatchResult, Spanned) -> Spanned +): Spannable { + val matcher = pattern.matcher(toString()) + .useAnchoringBounds(false) + .useTransparentBounds(true) + val result = SpannableStringBuilder(this) + while (matcher.find()) { + val matchResult = matcher.toMatchResult() + val matchedSequence = result.subSequence(matchResult.start(), matchResult.end()) as Spanned + val replacement = callback(matchResult, matchedSequence) + result.replace(matchResult.start(), matchResult.end(), replacement) + matcher.reset(result).region(matchResult.start() + replacement.length, result.length) + } + return result +} 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 d718e2029..735762b0b 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 @@ -45,7 +45,7 @@ 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.checkout.CheckOutFragment; import ch.admin.bag.dp3t.checkin.checkinflow.QrCodeScannerFragment; import ch.admin.bag.dp3t.checkin.models.CrowdNotifierErrorState; import ch.admin.bag.dp3t.contacts.ContactsFragment; @@ -133,6 +133,11 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat setupScrollBehavior(); setupCovidCodeCard(); + AccessibilityUtil.setButtonAccessibilityDelegate(tracingCard); + AccessibilityUtil.setButtonAccessibilityDelegate(cardNotifications); + AccessibilityUtil.setButtonAccessibilityDelegate(checkinCard); + AccessibilityUtil.setButtonAccessibilityDelegate(covidCodeCard); + showEndIsolationDialogIfNecessary(); } 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 index cca0b3039..c1fb10af6 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/InformFragment.kt @@ -24,6 +24,8 @@ 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 ch.admin.bag.dp3t.util.isAccessibilityActive +import ch.admin.bag.dp3t.util.requestAccessibilityFocus import org.dpppt.android.sdk.DP3T private const val REGEX_CODE_PATTERN = "\\d{" + ChainedEditText.NUM_CHARACTERS + "}" @@ -46,7 +48,12 @@ class InformFragment : TraceKeyShareBaseFragment() { override fun onResume() { super.onResume() - binding.covidcodeInput.requestFocus() + + if (requireContext().isAccessibilityActive()) { + binding.informTitle.requestAccessibilityFocus() + } else { + binding.covidcodeInput.requestFocus() + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { 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 560a4dc46..19b422640 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 @@ -23,6 +23,7 @@ import ch.admin.bag.dp3t.R; import ch.admin.bag.dp3t.storage.SecureStorage; import ch.admin.bag.dp3t.travel.TravelUtils; +import ch.admin.bag.dp3t.util.AccessibilityUtil; public class InformIntroFragment extends Fragment { @@ -42,6 +43,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat }); ((InformActivity) requireActivity()).allowBackButton(true); + AccessibilityUtil.requestAccessibilityFocus(view.findViewById(R.id.inform_intro_title)); + SecureStorage secureStorage = SecureStorage.getInstance(getContext()); List countries = secureStorage.getInteropCountries(); ViewGroup countriesContainer = view.findViewById(R.id.inform_intro_travel); diff --git a/app/src/main/java/ch/admin/bag/dp3t/inform/views/ChainedEditText.java b/app/src/main/java/ch/admin/bag/dp3t/inform/views/ChainedEditText.java index be2003493..e2f3d1efe 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/inform/views/ChainedEditText.java +++ b/app/src/main/java/ch/admin/bag/dp3t/inform/views/ChainedEditText.java @@ -72,6 +72,7 @@ private void init(Context context, AttributeSet attrs, int defStyleAttr) { shadowEditText = new EditText(context); shadowEditText.setHeight(1); shadowEditText.setWidth(1); + shadowEditText.setHint(R.string.inform_code_title); shadowEditText.setBackgroundColor(Color.TRANSPARENT); shadowEditText.setCursorVisible(false); shadowEditText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); @@ -119,6 +120,8 @@ public void afterTextChanged(Editable s) { focusEditText(); } }); + + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); } private void focusEditText() { 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 index 17d29baf6..59b441839 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/infotab/InfoTabFragment.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/infotab/InfoTabFragment.kt @@ -12,7 +12,7 @@ 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.checkout.CheckOutFragment import ch.admin.bag.dp3t.databinding.FragmentInfoTabBinding import ch.admin.bag.dp3t.extensions.showFragment import ch.admin.bag.dp3t.inform.InformActivity 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 7fa55855d..69b04653f 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 @@ -21,6 +21,7 @@ import androidx.fragment.app.Fragment; import ch.admin.bag.dp3t.R; +import ch.admin.bag.dp3t.util.AccessibilityUtil; import ch.admin.bag.dp3t.util.AssetUtil; import ch.admin.bag.dp3t.util.UlTagHandler; import ch.admin.bag.dp3t.util.UrlUtil; @@ -51,10 +52,13 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat 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 termsOfUseHeader = view.findViewById(R.id.conditions_of_use_header_container); View termsOfUseContainer = view.findViewById(R.id.onboarding_disclaimer_terms_of_use_container); + View dataProtectionHeader = view.findViewById(R.id.data_protection_header_container); View dataProtectionContainer = view.findViewById(R.id.onboarding_disclaimer_data_protection_container); - view.findViewById(R.id.data_protection_header_container).setOnClickListener(v -> { + AccessibilityUtil.setExpansionToggleStateAccessibilityDelegate(dataProtectionHeader, dataProtectionContainer); + dataProtectionHeader.setOnClickListener(v -> { if (dataProtectionContainer.getVisibility() == View.VISIBLE) { dataProtectionContainer.setVisibility(View.GONE); v.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white)); @@ -68,7 +72,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat .start(); }); - view.findViewById(R.id.conditions_of_use_header_container).setOnClickListener(v -> { + AccessibilityUtil.setExpansionToggleStateAccessibilityDelegate(termsOfUseHeader, termsOfUseContainer); + termsOfUseHeader.setOnClickListener(v -> { if (termsOfUseContainer.getVisibility() == View.VISIBLE) { termsOfUseContainer.setVisibility(View.GONE); v.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.white)); @@ -82,8 +87,10 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat .start(); }); - dataProtectionToOnlineVersionButton.setOnClickListener(v -> { openOnlineVersion();}); - termsOfUseToOnlineVersionButton.setOnClickListener(v -> { openOnlineVersion();}); + dataProtectionToOnlineVersionButton.setOnClickListener(v -> openOnlineVersion()); + AccessibilityUtil.setButtonAccessibilityDelegate(dataProtectionToOnlineVersionButton); + termsOfUseToOnlineVersionButton.setOnClickListener(v -> openOnlineVersion()); + AccessibilityUtil.setButtonAccessibilityDelegate(termsOfUseToOnlineVersionButton); 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/util/AccessibilityUtil.kt b/app/src/main/java/ch/admin/bag/dp3t/util/AccessibilityUtil.kt new file mode 100644 index 000000000..bac29f5af --- /dev/null +++ b/app/src/main/java/ch/admin/bag/dp3t/util/AccessibilityUtil.kt @@ -0,0 +1,46 @@ +@file:JvmName("AccessibilityUtil") + +package ch.admin.bag.dp3t.util + +import android.content.Context +import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.Button +import androidx.core.view.doOnLayout + +fun Context.isAccessibilityActive(): Boolean { + val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager? + return am != null && am.isEnabled && am.isTouchExplorationEnabled +} + +fun View.requestAccessibilityFocus() { + doOnLayout { + performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null) + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED) + } +} + +fun View.setExpansionToggleStateAccessibilityDelegate(expandableView: View) { + accessibilityDelegate = object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) { + info.addAction( + if (expandableView.visibility == View.VISIBLE) + AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE + else + AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND + ) + super.onInitializeAccessibilityNodeInfo(host, info) + } + } +} + +fun View.setButtonAccessibilityDelegate() { + accessibilityDelegate = object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.className = Button::class.java.name + } + } +} 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 index 4c5568e9a..27c39b9ff 100644 --- a/app/src/main/java/ch/admin/bag/dp3t/view/DateTimePicker.kt +++ b/app/src/main/java/ch/admin/bag/dp3t/view/DateTimePicker.kt @@ -70,8 +70,11 @@ class DateTimePicker @JvmOverloads constructor( formatter = dateFormatter typeface = font setSelectedTypeface(font) - setOnValueChangedListener { _, _, _ -> + setOnValueChangedListener { _, _, value -> changeListener?.onDateTimeChanged(getSelectedDateTime()) + post { + contentDescription = formatter.format(value) + } } } @@ -131,7 +134,10 @@ class DateTimePicker @JvmOverloads constructor( private fun updatePickerValues() { val dayDifference = ChronoUnit.DAYS.between(now.toLocalDate(), previousDateTime.toLocalDate()) val preSelectedValue = daysInPast + dayDifference - binding.datePicker.value = preSelectedValue.toInt() + binding.datePicker.apply { + value = preSelectedValue.toInt() + contentDescription = formatter.format(value) + } binding.hourPicker.value = previousDateTime.hour val minuteSelectionValue = previousDateTime.minute binding.minutePicker.value = minuteSelectionValue diff --git a/app/src/main/res/drawable/ic_check_filled.xml b/app/src/main/res/drawable/ic_check_filled.xml new file mode 100644 index 000000000..9e0ca4c0c --- /dev/null +++ b/app/src/main/res/drawable/ic_check_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 000000000..d0525b2d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index 8a57d94db..7e91ac271 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -14,6 +14,7 @@ diff --git a/app/src/main/res/layout/card_what_to_do_self_isolation.xml b/app/src/main/res/layout/card_what_to_do_self_isolation.xml index 0cf5b67f5..3467a72d1 100644 --- a/app/src/main/res/layout/card_what_to_do_self_isolation.xml +++ b/app/src/main/res/layout/card_what_to_do_self_isolation.xml @@ -93,7 +93,7 @@ android:layout_height="1dp" android:background="@color/grey_light" /> - + + + + + + + + + + + + +