Showing preview only (1,471K chars total). Download the full file or copy to clipboard to get everything.
Repository: covid19cz/erouska-android
Branch: develop
Commit: 23064413869f
Files: 355
Total size: 1.3 MB
Directory structure:
gitextract_yznxotpf/
├── .github/
│ ├── pull_request_template.txt
│ ├── stale.yml
│ └── workflows/
│ ├── deploy-develop.yml
│ └── deploy-master.yml
├── .gitignore
├── .idea/
│ └── codeStyles/
│ └── codeStyleConfig.xml
├── LICENSE
├── README.md
├── app/
│ ├── build.gradle
│ ├── libs/
│ │ └── play-services-nearby-exposurenotification-1.7.2-eap.aar
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ └── kotlin/
│ │ └── cz/
│ │ └── covid19cz/
│ │ └── erouska/
│ │ ├── helpers/
│ │ │ ├── Actions.kt
│ │ │ ├── ClickableLink.kt
│ │ │ └── TextMatchesIgnoringWhitespaceType.kt
│ │ ├── screens/
│ │ │ ├── A1Screen.kt
│ │ │ ├── A2Screen.kt
│ │ │ ├── A3Screen.kt
│ │ │ ├── B1Screen.kt
│ │ │ └── N1Screen.kt
│ │ ├── testRules/
│ │ │ └── DisableAnimationsRule.kt
│ │ └── tests/
│ │ └── ActivationTest.kt
│ ├── dev/
│ │ ├── google-services.json
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_launcher_background.xml
│ │ │ └── ic_launcher_foreground.xml
│ │ └── values/
│ │ ├── controls.xml
│ │ └── strings.xml
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/
│ │ │ └── cz/
│ │ │ └── covid19cz/
│ │ │ └── erouska/
│ │ │ ├── App.kt
│ │ │ ├── AppConfig.kt
│ │ │ ├── DI.kt
│ │ │ ├── db/
│ │ │ │ ├── DailySummariesDb.kt
│ │ │ │ ├── DailySummaryDao.kt
│ │ │ │ ├── DailySummaryEntity.kt
│ │ │ │ └── SharedPrefsRepository.kt
│ │ │ ├── exposurenotifications/
│ │ │ │ ├── ExposureCryptoTools.kt
│ │ │ │ ├── ExposureNotificationsErrorHandling.kt
│ │ │ │ ├── ExposureNotificationsRepository.kt
│ │ │ │ ├── Notifications.kt
│ │ │ │ ├── receiver/
│ │ │ │ │ └── ExposureNotificationBroadcastReceiver.kt
│ │ │ │ ├── service/
│ │ │ │ │ └── PushService.kt
│ │ │ │ └── worker/
│ │ │ │ ├── DownloadKeysWorker.kt
│ │ │ │ └── SelfCheckerWorker.kt
│ │ │ ├── ext/
│ │ │ │ ├── ByteArray.kt
│ │ │ │ ├── Context.kt
│ │ │ │ ├── Int.kt
│ │ │ │ ├── Long.kt
│ │ │ │ ├── Rx.kt
│ │ │ │ ├── String.kt
│ │ │ │ └── View.kt
│ │ │ ├── net/
│ │ │ │ ├── ExposureServerRepository.kt
│ │ │ │ ├── FirebaseFunctionsRepository.kt
│ │ │ │ ├── api/
│ │ │ │ │ ├── KeyServerApi.kt
│ │ │ │ │ └── VerificationServerApi.kt
│ │ │ │ ├── exception/
│ │ │ │ │ └── UnauthrorizedException.kt
│ │ │ │ └── model/
│ │ │ │ ├── CovidDataModel.kt
│ │ │ │ ├── DownloadedKeys.kt
│ │ │ │ ├── KeyServerModel.kt
│ │ │ │ └── VerificationServerModel.kt
│ │ │ ├── ui/
│ │ │ │ ├── about/
│ │ │ │ │ ├── AboutFragment.kt
│ │ │ │ │ ├── AboutVM.kt
│ │ │ │ │ └── entity/
│ │ │ │ │ └── AboutProfileItem.kt
│ │ │ │ ├── activation/
│ │ │ │ │ ├── ActivationFragment.kt
│ │ │ │ │ ├── ActivationNotificationsFragment.kt
│ │ │ │ │ ├── ActivationNotificationsVM.kt
│ │ │ │ │ ├── ActivationState.kt
│ │ │ │ │ └── ActivationVM.kt
│ │ │ │ ├── base/
│ │ │ │ │ ├── BaseActivity.kt
│ │ │ │ │ ├── BaseFragment.kt
│ │ │ │ │ ├── BaseVM.kt
│ │ │ │ │ └── UrlEvent.kt
│ │ │ │ ├── contacts/
│ │ │ │ │ ├── Contact.kt
│ │ │ │ │ ├── ContactsFragment.kt
│ │ │ │ │ ├── ContactsVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── ContactsCommandEvent.kt
│ │ │ │ ├── dashboard/
│ │ │ │ │ ├── DashboardCardView.kt
│ │ │ │ │ ├── DashboardFragment.kt
│ │ │ │ │ ├── DashboardVM.kt
│ │ │ │ │ ├── TravellerDashboardCardView.kt
│ │ │ │ │ └── event/
│ │ │ │ │ ├── DashboardCommandEvent.kt
│ │ │ │ │ ├── DisabledEvent.kt
│ │ │ │ │ └── GmsApiErrorEvent.kt
│ │ │ │ ├── efgs/
│ │ │ │ │ ├── EfgsFragment.kt
│ │ │ │ │ ├── EfgsVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── EfgsCommandEvent.kt
│ │ │ │ ├── efgsagreement/
│ │ │ │ │ ├── EfgsAgreementFragment.kt
│ │ │ │ │ └── EfgsAgreementVM.kt
│ │ │ │ ├── error/
│ │ │ │ │ ├── ErrorFragment.kt
│ │ │ │ │ ├── ErrorVM.kt
│ │ │ │ │ └── entity/
│ │ │ │ │ └── ErrorType.kt
│ │ │ │ ├── exposure/
│ │ │ │ │ ├── ExposureFragment.kt
│ │ │ │ │ ├── ExposureVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── ExposuresEvent.kt
│ │ │ │ ├── exposurehelp/
│ │ │ │ │ ├── ExposureHelpFragment.kt
│ │ │ │ │ ├── ExposureHelpVM.kt
│ │ │ │ │ └── entity/
│ │ │ │ │ ├── ExposureHelpData.kt
│ │ │ │ │ ├── ExposureHelpItem.kt
│ │ │ │ │ ├── ExposureHelpTitle.kt
│ │ │ │ │ └── ExposureHelpType.kt
│ │ │ │ ├── exposureinfo/
│ │ │ │ │ ├── ExposureInfoFragment.kt
│ │ │ │ │ └── ExposureInfoVM.kt
│ │ │ │ ├── help/
│ │ │ │ │ ├── HelpFragment.kt
│ │ │ │ │ ├── HelpVM.kt
│ │ │ │ │ └── data/
│ │ │ │ │ ├── AboutAppCategory.kt
│ │ │ │ │ ├── Category.kt
│ │ │ │ │ ├── FaqCategory.kt
│ │ │ │ │ ├── HowToCategory.kt
│ │ │ │ │ └── Question.kt
│ │ │ │ ├── helpcategory/
│ │ │ │ │ ├── HelpCategoryFragment.kt
│ │ │ │ │ └── HelpCategoryVM.kt
│ │ │ │ ├── helpquestion/
│ │ │ │ │ ├── HelpQuestionFragment.kt
│ │ │ │ │ └── HelpQuestionVM.kt
│ │ │ │ ├── helpsearch/
│ │ │ │ │ ├── HelpSearchFragment.kt
│ │ │ │ │ ├── HelpSearchVM.kt
│ │ │ │ │ ├── data/
│ │ │ │ │ │ └── SearchableQuestion.kt
│ │ │ │ │ └── ui/
│ │ │ │ │ └── SearchableItem.kt
│ │ │ │ ├── how/
│ │ │ │ │ ├── HowItWorksFragment.kt
│ │ │ │ │ ├── HowItWorksVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── HowItWorksEvent.kt
│ │ │ │ ├── main/
│ │ │ │ │ ├── MainActivity.kt
│ │ │ │ │ ├── MainActivityOld.kt
│ │ │ │ │ └── MainVM.kt
│ │ │ │ ├── mydata/
│ │ │ │ │ ├── CaseItemView.kt
│ │ │ │ │ ├── MyDataFragment.kt
│ │ │ │ │ └── MyDataVM.kt
│ │ │ │ ├── noverificationcode/
│ │ │ │ │ ├── NoVerificationCodeFragment.kt
│ │ │ │ │ ├── NoVerificationCodeVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── NoVerificationCodeEvent.kt
│ │ │ │ ├── permissions/
│ │ │ │ │ └── BasePermissionsFragment.kt
│ │ │ │ ├── publishsuccess/
│ │ │ │ │ ├── PublishSuccessFragment.kt
│ │ │ │ │ └── PublishSuccessVM.kt
│ │ │ │ ├── ragnarok/
│ │ │ │ │ └── RagnarokVM.kt
│ │ │ │ ├── recentexposures/
│ │ │ │ │ ├── RecentExposuresFragment.kt
│ │ │ │ │ ├── RecentExposuresVM.kt
│ │ │ │ │ └── entity/
│ │ │ │ │ └── RecentExposureGroupHeaderItem.kt
│ │ │ │ ├── sandbox/
│ │ │ │ │ ├── SandboxConfigFragment.kt
│ │ │ │ │ ├── SandboxConfigVM.kt
│ │ │ │ │ ├── SandboxConfigValues.kt
│ │ │ │ │ ├── SandboxDataFragment.kt
│ │ │ │ │ ├── SandboxDataVM.kt
│ │ │ │ │ ├── SandboxFragment.kt
│ │ │ │ │ ├── SandboxVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── SnackbarEvent.kt
│ │ │ │ ├── symptomdate/
│ │ │ │ │ ├── SymptomDateFragment.kt
│ │ │ │ │ ├── SymptomDateVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ ├── DatePickerEvent.kt
│ │ │ │ │ └── SymptomDateCommandEvent.kt
│ │ │ │ ├── traveller/
│ │ │ │ │ ├── TravellerFragment.kt
│ │ │ │ │ └── TravellerVM.kt
│ │ │ │ ├── update/
│ │ │ │ │ ├── efgs/
│ │ │ │ │ │ ├── EfgsUpdateFragment.kt
│ │ │ │ │ │ └── EfgsUpdateVM.kt
│ │ │ │ │ └── playservices/
│ │ │ │ │ ├── UpdatePlayServicesFragment.kt
│ │ │ │ │ ├── UpdatePlayServicesVM.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── UpdatePlayServicesEvent.kt
│ │ │ │ ├── verification/
│ │ │ │ │ ├── InvalidTokenException.kt
│ │ │ │ │ ├── NoKeysException.kt
│ │ │ │ │ ├── ReportExposureException.kt
│ │ │ │ │ ├── VerificationFragment.kt
│ │ │ │ │ ├── VerificationVM.kt
│ │ │ │ │ ├── VerifyException.kt
│ │ │ │ │ └── event/
│ │ │ │ │ └── VerificationCommandEvent.kt
│ │ │ │ └── welcome/
│ │ │ │ ├── WelcomeFragment.kt
│ │ │ │ ├── WelcomeVM.kt
│ │ │ │ └── event/
│ │ │ │ └── WelcomeCommandEvent.kt
│ │ │ └── utils/
│ │ │ ├── Analytics.kt
│ │ │ ├── CustomTabHelper.kt
│ │ │ ├── DeviceInfo.kt
│ │ │ ├── L.kt
│ │ │ ├── LocaleUtils.kt
│ │ │ ├── Markdown.kt
│ │ │ ├── MiscUtils.kt
│ │ │ ├── SharedPrefsLiveData.kt
│ │ │ ├── SupportEmailGenerator.kt
│ │ │ ├── Text.kt
│ │ │ └── ViewUtils.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── highlight_selector.xml
│ │ │ ├── ic_about.xml
│ │ │ ├── ic_ack_case.xml
│ │ │ ├── ic_act_case.xml
│ │ │ ├── ic_action_close.xml
│ │ │ ├── ic_action_up.xml
│ │ │ ├── ic_active.xml
│ │ │ ├── ic_antigen.xml
│ │ │ ├── ic_arrow_right.xml
│ │ │ ├── ic_balloon.xml
│ │ │ ├── ic_bluetooth_onboard.xml
│ │ │ ├── ic_calendar.xml
│ │ │ ├── ic_chat.xml
│ │ │ ├── ic_confirm.xml
│ │ │ ├── ic_contacts.xml
│ │ │ ├── ic_control.xml
│ │ │ ├── ic_cured.xml
│ │ │ ├── ic_data.xml
│ │ │ ├── ic_death_toll.xml
│ │ │ ├── ic_encounter.xml
│ │ │ ├── ic_error.xml
│ │ │ ├── ic_eval.xml
│ │ │ ├── ic_exposure_info.xml
│ │ │ ├── ic_face_mask.xml
│ │ │ ├── ic_google_play_services.xml
│ │ │ ├── ic_help.xml
│ │ │ ├── ic_home.xml
│ │ │ ├── ic_hospitalized.xml
│ │ │ ├── ic_how_it_works_banner.xml
│ │ │ ├── ic_injection_complete.xml
│ │ │ ├── ic_injection_first.xml
│ │ │ ├── ic_item_empty.xml
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ ├── ic_mask.xml
│ │ │ ├── ic_mzcr.xml
│ │ │ ├── ic_no_risky_encounter.xml
│ │ │ ├── ic_notif.xml
│ │ │ ├── ic_notifications_sent.xml
│ │ │ ├── ic_notifications_shown.xml
│ │ │ ├── ic_off_bluetooth.xml
│ │ │ ├── ic_off_location.xml
│ │ │ ├── ic_pause.xml
│ │ │ ├── ic_positive.xml
│ │ │ ├── ic_prevention.xml
│ │ │ ├── ic_privacy.xml
│ │ │ ├── ic_restriction.xml
│ │ │ ├── ic_risky_encounter.xml
│ │ │ ├── ic_shortcut_resume.xml
│ │ │ ├── ic_splashscreen_hands.xml
│ │ │ ├── ic_splashscreen_logo.xml
│ │ │ ├── ic_symptoms.xml
│ │ │ ├── ic_test.xml
│ │ │ ├── ic_travel.xml
│ │ │ ├── ic_update_expansion.xml
│ │ │ ├── ic_vacc.xml
│ │ │ ├── ic_warn.xml
│ │ │ └── launchscreen.xml
│ │ ├── drawable-anydpi-v24/
│ │ │ └── ic_notification_normal.xml
│ │ ├── drawable-night/
│ │ │ └── ic_splashscreen_hands.xml
│ │ ├── layout/
│ │ │ ├── activity_main.xml
│ │ │ ├── activity_ragnarok.xml
│ │ │ ├── dashboard_card_view.xml
│ │ │ ├── fragment_about.xml
│ │ │ ├── fragment_activation.xml
│ │ │ ├── fragment_activation_notifications.xml
│ │ │ ├── fragment_contacts.xml
│ │ │ ├── fragment_dashboard_plus.xml
│ │ │ ├── fragment_efgs.xml
│ │ │ ├── fragment_efgs_agreement.xml
│ │ │ ├── fragment_efgs_update.xml
│ │ │ ├── fragment_error.xml
│ │ │ ├── fragment_exposure.xml
│ │ │ ├── fragment_exposure_help.xml
│ │ │ ├── fragment_exposure_info.xml
│ │ │ ├── fragment_help.xml
│ │ │ ├── fragment_help_category.xml
│ │ │ ├── fragment_help_question.xml
│ │ │ ├── fragment_help_search.xml
│ │ │ ├── fragment_how_it_works.xml
│ │ │ ├── fragment_my_data.xml
│ │ │ ├── fragment_no_verification_code.xml
│ │ │ ├── fragment_play_services_update.xml
│ │ │ ├── fragment_publish_success.xml
│ │ │ ├── fragment_recent_exposures.xml
│ │ │ ├── fragment_sandbox.xml
│ │ │ ├── fragment_sandbox_config.xml
│ │ │ ├── fragment_sandbox_data.xml
│ │ │ ├── fragment_symptom_date.xml
│ │ │ ├── fragment_traveller.xml
│ │ │ ├── fragment_verification.xml
│ │ │ ├── fragment_welcome.xml
│ │ │ ├── item_contacts.xml
│ │ │ ├── item_daily_summary.xml
│ │ │ ├── item_exposure_help.xml
│ │ │ ├── item_exposure_help_title.xml
│ │ │ ├── item_exposure_window.xml
│ │ │ ├── item_help_about_category.xml
│ │ │ ├── item_help_faq_category.xml
│ │ │ ├── item_help_how_category.xml
│ │ │ ├── item_help_question.xml
│ │ │ ├── item_recent_exposure.xml
│ │ │ ├── item_recent_exposure_group_header.xml
│ │ │ ├── item_scan_instance.xml
│ │ │ ├── item_scan_instance_header.xml
│ │ │ ├── item_search.xml
│ │ │ ├── item_search_layout.xml
│ │ │ ├── item_tek.xml
│ │ │ ├── layout_sandbox_config_values.xml
│ │ │ ├── search_toolbar.xml
│ │ │ ├── traveller_dashboard_card_view.xml
│ │ │ └── view_data_item.xml
│ │ ├── menu/
│ │ │ ├── bottom_nav.xml
│ │ │ ├── dashboard.xml
│ │ │ ├── exposure.xml
│ │ │ └── onboarding.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── navigation/
│ │ │ └── nav_graph.xml
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ ├── controls.xml
│ │ │ ├── dimens.xml
│ │ │ ├── ids.xml
│ │ │ ├── strings-notranslate.xml
│ │ │ ├── strings.xml
│ │ │ ├── styles.xml
│ │ │ └── themes.xml
│ │ ├── values-cs/
│ │ │ └── strings.xml
│ │ ├── values-night/
│ │ │ └── colors.xml
│ │ ├── values-sk/
│ │ │ └── strings.xml
│ │ ├── xml/
│ │ │ ├── file_paths.xml
│ │ │ └── remote_config_defaults.xml
│ │ ├── xml-cs/
│ │ │ └── remote_config_defaults.xml
│ │ └── xml-sk/
│ │ └── remote_config_defaults.xml
│ ├── prod/
│ │ ├── google-services.json
│ │ └── res/
│ │ └── values/
│ │ └── controls.xml
│ └── test/
│ └── kotlin/
│ └── com/
│ └── covid19cz/
│ └── bt_tracing/
│ └── ExampleUnitTest.kt
├── arch/
│ ├── build.gradle
│ ├── gradle.properties
│ ├── proguard-rules.pro
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── arch/
│ │ ├── BaseApp.kt
│ │ ├── adapter/
│ │ │ ├── BaseRecyclerAdapter.kt
│ │ │ ├── RecyclerLayoutStrategy.kt
│ │ │ ├── SingleTypeRecyclerAdapter.kt
│ │ │ └── StrategyRecyclerAdapter.kt
│ │ ├── binding/
│ │ │ ├── EditTextBindings.kt
│ │ │ ├── ImageViewBindings.kt
│ │ │ ├── ProgressbarBindings.kt
│ │ │ ├── RecyclerViewBindings.kt
│ │ │ ├── TextViewBindings.kt
│ │ │ ├── ViewBindings.kt
│ │ │ └── WebViewBindings.kt
│ │ ├── event/
│ │ │ ├── LiveEvent.kt
│ │ │ ├── LiveEventMap.kt
│ │ │ ├── NavigationEvent.kt
│ │ │ ├── NavigationGraphEvent.kt
│ │ │ └── SingleLiveEvent.java
│ │ ├── extensions/
│ │ │ └── navExtensions.kt
│ │ ├── livedata/
│ │ │ └── SafeMutableLiveData.kt
│ │ ├── utils/
│ │ │ └── NullableUtils.kt
│ │ ├── view/
│ │ │ ├── BaseArchActivity.kt
│ │ │ ├── BaseArchDialogFragment.kt
│ │ │ ├── BaseArchFragment.kt
│ │ │ └── BaseDialogFragment.kt
│ │ └── viewmodel/
│ │ ├── BaseArchViewModel.kt
│ │ └── BaseDialogViewModel.kt
│ └── res/
│ ├── layout/
│ │ ├── base_dialog.xml
│ │ ├── base_view.xml
│ │ └── item_recycler.xml
│ └── values/
│ ├── ids.xml
│ └── strings.xml
├── build.gradle
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── meta/
│ └── debug.keystore
├── release.sh
└── settings.gradle
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/pull_request_template.txt
================================================
# Description
<# Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. #>
## Screenshots 📸
<details open><summary>Show/Hide content</summary>
<# Please provide screenshots for any visual change. The best way is to take screenshots directly from the app with production-like data. #>
</details>
# How Has This Been Tested? 👨🔬
<# Please describe the tests that you ran to verify your changes. #>
## What Has Not Been Tested? 🙅🏻♂️
<# Please describe what has not been possible to test neither automatically nor manually. #>
# Checklist ✅
- [ ] I have performed a self-review of my own code.
- [ ] I have commented my code, particularly in hard-to-understand areas.
- [ ] I have checked all visual changes in both light and dark mode.
================================================
FILE: .github/stale.yml
================================================
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
================================================
FILE: .github/workflows/deploy-develop.yml
================================================
name: Deploy production app
on:
push:
branches:
- develop
jobs:
build:
runs-on: ubuntu-18.04
steps:
- name: Check out code
uses: actions/checkout@v1
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: '11'
- name: Recover Gradle cache
uses: actions/cache@v1
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
- name: Download release certificate
id: release_cert
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: 'erouska_release.jks'
encodedString: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
- name: Build release apps
run: ./gradlew assembleDevRelease
env:
EROUSKA_RELEASE_KEYSTORE_PATH: ${{ steps.release_cert.outputs.filePath }}
EROUSKA_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
EROUSKA_RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
- name: Publish artefact
uses: actions/upload-artifact@v1
with:
name: app-releases
path: app/build/outputs
- name: Upload DEV app to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1.2.1
with:
appId: 1:1077972356575:android:75624eb96818aa0d61886a
token: ${{ secrets.FIREBASE_TOKEN }}
groups: internal-test
file: app/build/outputs/apk/dev/release/covid19-cz-dev-release.apk
================================================
FILE: .github/workflows/deploy-master.yml
================================================
name: Deploy production app
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-18.04
steps:
- name: Check out code
uses: actions/checkout@v1
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: '11'
- name: Recover Gradle cache
uses: actions/cache@v1
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
- name: Download release certificate
id: release_cert
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: 'erouska_release.jks'
encodedString: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
- name: Build release apps
run: ./gradlew assembleProdRelease bundleProdRelease
env:
EROUSKA_RELEASE_KEYSTORE_PATH: ${{ steps.release_cert.outputs.filePath }}
EROUSKA_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
EROUSKA_RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
- name: Publish artefact
uses: actions/upload-artifact@v1
with:
name: app-releases
path: app/build/outputs
- name: Upload PROD app to Firebase App Distribution
uses: wzieba/Firebase-Distribution-Github-Action@v1.2.1
with:
appId: 1:941144972907:android:937903c1584d72a673db2e
token: ${{ secrets.FIREBASE_TOKEN }}
groups: internal-test
file: app/build/outputs/apk/prod/release/covid19-cz-prod-release.apk
================================================
FILE: .gitignore
================================================
# Created by https://www.gitignore.io/api/intellij,android,gradle,windows,osx
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio
*.iml
## Directory-based project format:
#.idea/
# if you remove the above rule, at least ignore the following:
# User-specific stuff:
.idea/workspace.xml
.idea/tasks.xml
.idea/dictionaries
# Sensitive or high-churn files:
.idea/dataSources.ids
.idea/dataSources.xml
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
.idea/jarRepositories.xml
.idea/misc.xml
.idea/modules.xml
.idea/runConfigurations.xml
.idea/vcs.xml
.idea/compiler.xml
.idea/caches
.idea/.name
.idea/navEditor.xml
.idea/assetWizardSettings.xml
.idea/codeStyles/Project.xml
# Gradle:
.idea/gradle.xml
.idea/libraries
# Mongo Explorer plugin:
.idea/mongoSettings.xml
## File-based project format:
*.ipr
*.iws
## Plugin-specific files:
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
### Android ###
# Built application files
*.apk
*.ap_
# Files for the Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
# Gradle files
.gradle
.gradle/
build/
/build
# Local configuration file (sdk path, etc)
local.properties
# signing.properties
# *.keystore
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
### Android Patch ###
gen-external-apklibs
# Ignore Gradle GUI config
gradle-app.setting
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
!gradle-wrapper.jar
### Windows ###
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
### OSX ###
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
.idea/inspectionProfiles/Project_Default.xml
================================================
FILE: .idea/codeStyles/codeStyleConfig.xml
================================================
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 Ministry of Health of the Czech Republic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# erouska-android
[<img src="https://lh3.googleusercontent.com/cjsqrWQKJQp9RFO7-hJ9AfpKzbUb_Y84vXfjlP0iRHBvladwAfXih984olktDhPnFqyZ0nu9A5jvFwOEQPXzv7hr3ce3QVsLN8kQ2Ao=s0">](https://play.google.com/store/apps/details?id=cz.covid19cz.erouska)
Read our **FAQ**: [Czech](https://erouska.cz/caste-dotazy), [English](https://erouska.cz/en/caste-dotazy)
eRouška (_rouška_ = _face mask_ in Czech) helps to fight against COVID-19.
eRouška uses Bluetooth to scan the area around the device for other eRouška users and saves the data of these encounters.
It's the only app in Czechia authorized to use Exposure Notifications API from Apple/Google.
## Who is developing eRouška?
Starting with version 2.0, the eRouška application is developed by the Ministry of Health in collaboration with the National Agency for Communication and Information Technologies ([NAKIT](https://nakit.cz/)). Earlier versions of eRouška application were developed by a team of volunteers from the [COVID19CZ](https://covid19cz.cz) community. Most of original eRouška developers continue to work on newer versions in the NAKIT team.
## International cooperation
We are open-source from day one and we will be happy to work with people in other countries if they want to develop a similar app. Contact [David Vávra](mailto:david.vavra@erouska.cz) for technical details.
## Building the App from the source code
Clone this repository and import the project into Android Studio. Make sure you have JDK 8.
Run:
`./gradlew assembleDevDebug`
## Contributing
We are happy to accept pull requests! See [Git Workflow](#git-workflow).
If you want to become a more permanent part of the team, join [our Slack](https://covid19cz.slack.com), channel _#erouska_.
## Translations
Help us translate to your language or if you see a problem with translation, fix it. Our translation is open to volunteers [at OneSky](https://covid19cz.oneskyapp.com/).
## <a name="git-workflow"></a>Git workflow
- Work in a fork then send a pull request to the `develop` branch.
- Pull requests are merged with `squash commits`.
- Admins rebase `develop` to `master` using the script below. This triggers a release build.
## eRouška release process
eRouška uses GitHub Actions. A push to master branch triggers an App build. Then the App is published to [Firebase App Distribution](https://firebase.google.com/docs/app-distribution).
There are two variants of the App: **DEV** and **PROD**. **PROD** is also built as an App Bundle artefact, that needs to be manually uploaded to Google Play.
Versioning is automatic: major and minor version is in Git, patch is _versionCode_ (a number of commits from the start).
Release is done by executing the release.sh script. Right click it on Android Studio and hit Run 'release.sh' or execute via command line.
If it fails, it fails. Most likely your master has different history from origin. That should never be the case, so you should fix it.
Make sure to update translations & RC defaults before release (next section).
## Updating translations
- Update only Czech and English file in a PR, don't upload anything to OneSky
- Don't put Czech strings into English file, add there either your English translation or `TODO: translate`
- Before every release, the person who is preparing the release will upload Czech file into OneSky and notify translators
- After it's translated, push English and Slovak file from OneSky to develop. Don't update Czech file.
## Updating RC defaults
- Don't add any RC defaults to a pull request, change it only in Dev RC for testing ([follow this guide](https://github.com/covid19cz/erouska-remote-config#how-to-add-new-localizable-rc-key))
- Before every release, the person who is preparing the release should update RC defaults and push them to develop
================================================
FILE: app/build.gradle
================================================
apply plugin: 'com.android.application'
apply plugin: 'dagger.hilt.android.plugin'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply plugin: 'com.google.firebase.crashlytics'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "cz.covid19cz.erouska"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode rootProject.ext.commitCount()
versionName rootProject.ext.versionName
archivesBaseName = "covid19-cz"
multiDexEnabled true
// If we support another language, add it here
def supportedLanguages = ["en", "cs", "sk"]
resConfigs supportedLanguages
buildConfigField "String[]", "SUPPORTED_LANGUAGES", "{\"" + supportedLanguages.join("\",\"") + "\"}"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
flavorDimensions "environment"
testBuildType "debug"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
}
prod {
dimension "environment"
}
}
signingConfigs {
debug {
storeFile file("../meta/debug.keystore")
}
release {
storeFile file(System.getenv("EROUSKA_RELEASE_KEYSTORE_PATH") ?: "No CI")
storePassword System.getenv("EROUSKA_RELEASE_KEYSTORE_PASSWORD")
keyAlias "covid19cz"
keyPassword System.getenv("EROUSKA_RELEASE_KEY_PASSWORD")
}
}
buildTypes {
release {
debuggable false
minifyEnabled true
shrinkResources true
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
debuggable true
minifyEnabled false
signingConfig signingConfigs.debug
}
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
debug.java.srcDirs += 'src/debug/kotlin'
release.java.srcDirs += 'src/release/kotlin'
androidTest.java.srcDirs += 'src/androidTest/kotlin'
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildFeatures {
dataBinding = true
}
lintOptions {
disable 'MissingTranslation'
}
packagingOptions {
exclude 'META-INF/main.kotlin_module'
}
}
androidExtensions {
experimental = true
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.32"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.5"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.3.7'
// Android Basics
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.core:core-ktx:1.6.0'
implementation "androidx.appcompat:appcompat:1.3.1"
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation "com.google.android.material:material:1.4.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.browser:browser:1.3.0"
implementation "com.google.android.play:core-ktx:1.8.1"
implementation "androidx.work:work-runtime-ktx:2.7.0"
// Arch
implementation project(':arch')
// Navigation
implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
// Dagger-Hilt
implementation "com.google.dagger:hilt-android:2.38.1"
kapt "com.google.dagger:hilt-android-compiler:2.38.1"
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
//RxJava
implementation "io.reactivex.rxjava2:rxjava:2.2.17"
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
//RxPermisssions
implementation 'com.github.tbruyelle:rxpermissions:0.10.2'
// ViewModel and LiveData
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
//Room
implementation "androidx.room:room-runtime:2.3.0"
kapt "androidx.room:room-compiler:2.3.0"
implementation "androidx.room:room-ktx:2.3.0"
// Gson
implementation 'com.google.code.gson:gson:2.8.8'
// Firebase
implementation platform('com.google.firebase:firebase-bom:26.0.0')
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-auth'
implementation 'com.google.firebase:firebase-config-ktx'
implementation 'com.google.firebase:firebase-functions-ktx'
implementation 'com.google.firebase:firebase-storage-ktx'
implementation 'com.google.firebase:firebase-messaging-ktx'
implementation 'com.google.firebase:firebase-crashlytics'
// Play Services
implementation 'com.google.android.play:core:1.10.2'
implementation 'com.google.android.gms:play-services-base:17.6.0'
implementation 'com.google.android.gms:play-services-basement:17.6.0'
implementation 'com.google.android.gms:play-services-safetynet:17.0.1'
implementation 'com.google.android.gms:play-services-tasks:17.2.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Markdown
implementation "io.noties.markwon:core:4.3.1"
implementation "io.noties.markwon:html:4.3.1"
implementation "io.noties.markwon:inline-parser:4.3.1"
implementation 'io.noties.markwon:image-glide:4.3.1'
implementation 'com.atlassian.commonmark:commonmark-ext-autolink:0.12.1'
implementation 'org.apache.commons:commons-lang3:3.11'
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.9.0'
// Others
implementation 'com.android.support:customtabs:28.0.0'
implementation 'com.jaredrummler:android-device-names:2.0.0'
implementation 'com.jakewharton.threetenabp:threetenabp:1.2.4'
// JWT
implementation 'com.auth0.android:jwtdecode:2.0.0'
// Tests
testImplementation 'junit:junit:4.13'
androidTestImplementation 'org.awaitility:awaitility:3.1.6'
androidTestImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation "org.koin:koin-test:2.0.1"
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
androidTestUtil 'androidx.test:orchestrator:1.2.0'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
}
================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Makes debugging easier
-dontobfuscate
-keepattributes SourceFile,LineNumberTable
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/helpers/Actions.kt
================================================
package cz.covid19cz.erouska.helpers
import android.app.Instrumentation
import android.content.Intent
import android.view.View
import androidx.annotation.StringRes
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.Matcher
import org.hamcrest.core.AllOf
const val RETRY_TIMEOUT = 10L
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
fun click(element: Matcher<View>): ViewInteraction = onView(element).perform(ViewActions.click())
fun click(id: Int): ViewInteraction = click(withId(id))
fun scrollTo(id: Int): ViewInteraction = onView(withId(id)).perform(ViewActions.scrollTo())
fun clickUiAutomator(buttonText: String) = device.findObject(UiSelector().clickable(true).textStartsWith(buttonText)).click() // startsWith because it is case insensitive
fun clickUiAutomatorByResourceId(resourceId: String) = device.findObject(UiSelector().resourceId(resourceId)).click()
fun checkMatchesString(id: Int, @StringRes stringId: String): ViewInteraction = onView(withId(id)).check(
ViewAssertions.matches(withText(stringId))
)
fun checkMatchesSubString(id: Int, @StringRes stringId: String): ViewInteraction = onView(withId(id)).check(
ViewAssertions.matches(withSubstring(stringId))
)
fun checkMatchesContainsString(id: Int, @StringRes stringId: String): ViewInteraction = onView(withId(id)).check(
ViewAssertions.matches(withText(containsString(stringId)))
)
fun checkDisplayed(id: Int): ViewInteraction = onView(withId(id)).check(
ViewAssertions.matches(
isDisplayed()
)
)
fun checkDisplayed(text: String): ViewInteraction = onView(withText(text)).check(
ViewAssertions.matches(
isDisplayed()
)
)
fun typeText(id: Int, @StringRes text: String): ViewInteraction = onView(withId(id)).perform(ViewActions.typeText(text),
ViewActions.closeSoftKeyboard()
)
/**
* verify link
* @param element element that has link
* @param url link that should be open
* @param clickableText optional pass if only part of the element is clickable
*/
fun verifyLink(element: Matcher<View>, url: String, clickableText: String? = null) {
Intents.init()
val expectedIntent = AllOf.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(url)
)
Intents.intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null))
if(clickableText.isNullOrBlank()) {
onView(element).perform(ViewActions.click())
} else {
onView(element).perform(ViewActions.openLinkWithText(TextMatchesIgnoringWhitespaceType(clickableText)))
}
Intents.intended(expectedIntent)
Intents.release()
}
fun verifyMultipleLinks(element: Matcher<View>, urlTextPairs: ArrayList<ClickableLink>) {
urlTextPairs.map {clickableLink -> verifyLink(element, clickableLink.url, clickableLink.clickableText)}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/helpers/ClickableLink.kt
================================================
package cz.covid19cz.erouska.helpers
class ClickableLink(var url: String, var clickableText: String)
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/helpers/TextMatchesIgnoringWhitespaceType.kt
================================================
package cz.covid19cz.erouska.helpers
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
class TextMatchesIgnoringWhitespaceType(string: String?) :
TypeSafeMatcher<String>() {
private val string: String
override fun matchesSafely(item: String): Boolean {
return normalizeWhitespaces(string).equals(normalizeWhitespaces(item), ignoreCase = true)
}
override fun describeTo(description: Description) {
description.appendText("Expected same strings")
}
private fun normalizeWhitespaces(string: String): String {
return string.replace("\\s".toRegex(), "")
}
init {
requireNotNull(string) { "Non-null value required by TextMatchesIgnoringWhitespaceType()" }
this.string = string
}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/A1Screen.kt
================================================
package cz.covid19cz.erouska.screens
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.helpers.checkDisplayed
import cz.covid19cz.erouska.helpers.click
object A1Screen {
fun startActivation() {
click(R.id.welcome_continue_btn)
}
fun checkAllPartsDisplayed() {
checkDisplayed(R.id.welcome_title)
checkDisplayed(R.id.welcome_desc)
checkDisplayed(R.id.welcome_help_btn)
checkDisplayed(R.id.mzcr_icon)
checkDisplayed(R.id.welcome_continue_btn)
}
fun goToHelp() {
click(R.id.welcome_help_btn)
}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/A2Screen.kt
================================================
package cz.covid19cz.erouska.screens
import android.bluetooth.BluetoothAdapter
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.helpers.checkDisplayed
import cz.covid19cz.erouska.helpers.click
import cz.covid19cz.erouska.helpers.clickUiAutomatorByResourceId
object A2Screen {
fun checkAllPartsDisplayed() {
checkDisplayed(R.id.notifications_img)
checkDisplayed(R.id.notifications_title)
checkDisplayed(R.id.notifications_body_1)
checkDisplayed(R.id.notifications_body_2)
checkDisplayed(R.id.enable_btn)
}
/**
* Check if bluetooth is turn on and if not turn it on using the app
*/
fun enableBt() {
val mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (!mBluetoothAdapter.isEnabled) {
click(R.id.enable_btn)
clickUiAutomatorByResourceId("android:id/button1")
}
}
fun turnOnNotifications() {
click(R.id.enable_btn)
}
fun acceptCovidActivation() {
clickUiAutomatorByResourceId("android:id/button1")
}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/A3Screen.kt
================================================
package cz.covid19cz.erouska.screens
import androidx.test.espresso.matcher.ViewMatchers.withId
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.helpers.checkDisplayed
import cz.covid19cz.erouska.helpers.click
import cz.covid19cz.erouska.helpers.verifyLink
import cz.covid19cz.erouska.screens.N1Screen.TERMS_OF_USE_URL
object A3Screen {
fun checkAllPartsDisplayed() {
checkDisplayed(R.id.img_privacy)
checkDisplayed(R.id.privacy_header)
checkDisplayed(R.id.privacy_body_1)
checkDisplayed(R.id.privacy_body_2)
checkDisplayed(R.id.activate_btn)
}
fun checkTermsOfUseLink() {
verifyLink(withId(R.id.privacy_body_2), TERMS_OF_USE_URL, "podmínkách používání")
}
fun finishActivation() {
click(R.id.activate_btn)
}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/B1Screen.kt
================================================
package cz.covid19cz.erouska.screens
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.helpers.RETRY_TIMEOUT
import cz.covid19cz.erouska.helpers.checkDisplayed
import org.awaitility.Awaitility.await
import java.util.concurrent.TimeUnit
object B1Screen {
fun checkActiveScreen() {
await().ignoreExceptions().atMost(RETRY_TIMEOUT, TimeUnit.SECONDS).untilAsserted {
checkDisplayed(R.id.app_running_image)
}
checkDisplayed(R.id.app_running_title)
checkDisplayed(R.id.app_running_body)
checkDisplayed(R.id.app_running_body_secondary)
checkDisplayed(R.id.buttonStop)
}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/N1Screen.kt
================================================
package cz.covid19cz.erouska.screens
import androidx.test.espresso.matcher.ViewMatchers.withId
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.helpers.ClickableLink
import cz.covid19cz.erouska.helpers.verifyMultipleLinks
object N1Screen {
private const val EROUSKA_BASE_URL = "https://erouska.cz/"
private const val AUDIT_URL = "${EROUSKA_BASE_URL}audit-kod"
private const val COVIDCZ_GITHUB_URL = "https://github.com/covid19cz/"
private const val IOS_GITHUB_URL = "${COVIDCZ_GITHUB_URL}erouska-ios"
private const val ANDROID_GITHUB_URL = "${COVIDCZ_GITHUB_URL}erouska-android"
private const val APPSTORE_URL = "https://apps.apple.com/cz/app/erou%C5%A1ka/id1509210215"
private const val GOOGLE_PLAY_URL = "https://play.google.com/store/apps/details?id=cz.covid19cz.erouska"
private const val APPLE_TRACKING_URL = "https://www.apple.com/covid19/contacttracing"
private const val GOOGLE_TRACKING_URL = "https://www.google.com/covid19/exposurenotifications/"
private const val CHYTRA_KARANTENA_URL = "https://koronavirus.mzcr.cz/chytra-karantena/"
private const val COVID_MZCR_URL = "https://koronavirus.mzcr.cz/"
const val TERMS_OF_USE_URL = "${EROUSKA_BASE_URL}podminky-pouzivani"
fun checkScreenAndLink() {
val descriptionElement = withId(R.id.help_desc)
val helpClickableLinks = arrayListOf(
ClickableLink("${EROUSKA_BASE_URL}vyhodnoceni-rizika","Spolehlivost vyhodnocení rizikového kontaktu"),
ClickableLink("${TERMS_OF_USE_URL}#technicke","Technické podmínky v Podmínkách zpracování"),
ClickableLink(GOOGLE_PLAY_URL,"Google Play (Android)"),
ClickableLink(APPSTORE_URL, "App Store (iOS)"),
ClickableLink(TERMS_OF_USE_URL,"Informacích o zpracování osobních údajů v aplikaci eRouška 2.0"),
ClickableLink(COVID_MZCR_URL,"na webu Ministerstva zdravotnictví ČR"),
ClickableLink(CHYTRA_KARANTENA_URL,"chytré karantény"),
ClickableLink(APPLE_TRACKING_URL,"Apple (anglicky)"),
ClickableLink(GOOGLE_TRACKING_URL,"Google (česky)"),
ClickableLink(TERMS_OF_USE_URL,"Informace o zpracování osobních údajů v aplikaci eRouška 2.0"),
ClickableLink(AUDIT_URL,"Audit zdrojového kódu aplikace"),
ClickableLink(TERMS_OF_USE_URL,"Informacích o zpracování osobních údajů v rámci aplikace eRouška 2.0"),
ClickableLink(ANDROID_GITHUB_URL,"Android"),
ClickableLink(IOS_GITHUB_URL,"iOS"),
ClickableLink(AUDIT_URL,"prověřují nezávislé autority"),
ClickableLink(TERMS_OF_USE_URL,"nepracuje s osobními údaji"),
ClickableLink(TERMS_OF_USE_URL,"Informacích o zpracování osobních údajů v rámci aplikace eRouška")
)
verifyMultipleLinks(descriptionElement, helpClickableLinks)
}
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/testRules/DisableAnimationsRule.kt
================================================
package cz.covid19cz.erouska.testRules
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Test rule for disabling animations before test start.
* Animations are re-enabled after test finish.
*
* @author Michal Kubele (michal.kubele@gmail.com)
*/
class DisableAnimationsRule : TestRule {
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
companion object {
private const val DISABLED = 0
private const val ENABLED = 1
private const val TRANSITION_ANIMATION_SCALE = "settings put global transition_animation_scale %d"
private const val WINDOW_ANIMATION_SCALE = "settings put global window_animation_scale %d"
private const val ANIMATOR_DURATION_SCALE = "settings put global animator_duration_scale %d"
}
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
disableAnimations()
try {
base.evaluate()
} finally {
enableAnimations()
}
}
}
}
internal fun enableAnimations() {
device.run {
executeCommand(TRANSITION_ANIMATION_SCALE, ENABLED)
executeCommand(WINDOW_ANIMATION_SCALE, ENABLED)
executeCommand(ANIMATOR_DURATION_SCALE, ENABLED)
}
}
internal fun disableAnimations() {
device.run {
executeCommand(TRANSITION_ANIMATION_SCALE, DISABLED)
executeCommand(WINDOW_ANIMATION_SCALE, DISABLED)
executeCommand(ANIMATOR_DURATION_SCALE, DISABLED)
}
}
}
/**
* Executes provided shell [command] with arguments [args] on the device.
*
* @param command command to run
* @param args arguments for command
*/
fun UiDevice.executeCommand(command: String, vararg args: Any) {
this.executeShellCommand(command.format(*args))
}
================================================
FILE: app/src/androidTest/kotlin/cz/covid19cz/erouska/tests/ActivationTest.kt
================================================
package cz.covid19cz.erouska.tests
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import cz.covid19cz.erouska.screens.*
import cz.covid19cz.erouska.testRules.DisableAnimationsRule
import cz.covid19cz.erouska.ui.main.MainActivityOld
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ActivationTest {
@get:Rule
val disableAnimationsRule = DisableAnimationsRule()
@get:Rule
val activityRule: ActivityTestRule<MainActivityOld> = ActivityTestRule(MainActivityOld::class.java)
@Test
fun activationTest() {
A1Screen.run {
checkAllPartsDisplayed()
startActivation()
}
A2Screen.run {
checkAllPartsDisplayed()
enableBt()
turnOnNotifications()
acceptCovidActivation()
}
A3Screen.run {
checkTermsOfUseLink()
checkAllPartsDisplayed()
finishActivation()
}
B1Screen.checkActiveScreen()
}
@Test
fun checkHelpScreenTest() {
A1Screen.goToHelp()
N1Screen.checkScreenAndLink()
}
}
================================================
FILE: app/src/dev/google-services.json
================================================
{
"project_info": {
"project_number": "382369682317",
"firebase_url": "https://erouska-key-server-dev.firebaseio.com",
"project_id": "erouska-key-server-dev",
"storage_bucket": "erouska-key-server-dev.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:382369682317:android:eae01d4d3e32686d0f23c4",
"android_client_info": {
"package_name": "cz.covid19cz.erouska.dev"
}
},
"oauth_client": [
{
"client_id": "382369682317-55si8a5km4pp4s3af88ehcn6sb5ol0ph.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCvhX7EQRKWpX1XYmU5wiyW2PIetK1EX6U"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "382369682317-55si8a5km4pp4s3af88ehcn6sb5ol0ph.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "382369682317-oa2dc4siamp24t9tk4u9gfvbs6g4of3h.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "cz.covid19cz.erouska.dev"
}
}
]
}
}
}
],
"configuration_version": "1"
}
================================================
FILE: app/src/dev/res/drawable/ic_launcher_background.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.2109375"
android:scaleY="0.2109375">
<path
android:pathData="M0,0h512v512h-512z"
android:fillColor="#EE225B"/>
</group>
<group android:scaleX="0.14132813"
android:scaleY="0.14132813"
android:translateX="17.82"
android:translateY="17.82">
<group>
<clip-path
android:pathData="M0,0h512v512h-512z"/>
<path
android:pathData="M96,360.4l151.6,151.6l264.4,0l0,-240.7l-96,-95.6l-320,184.7"
android:fillColor="#E01A53"/>
</group>
<group>
<clip-path
android:pathData="M0,0h512v512h-512z"/>
<path
android:pathData="M405.8,352.5c1.2,4.7 53.1,-31.5 106.2,-35.2"
android:strokeWidth="23"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M106.2,352.5C105,357.2 53.1,321 0,317.3"
android:strokeWidth="23"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M405.8,159.7c1.2,-4.7 53.1,31.5 106.2,35.2"
android:strokeWidth="23"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
<path
android:pathData="M106.2,159.7C105,155 53.1,191.2 0,194.9"
android:strokeWidth="23"
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"/>
</group>
<path
android:pathData="M401,154.6v55.7L236,191l20,-75C333.2,116 400.3,154.3 401,154.6z"
android:fillColor="#A4DBDD"/>
<path
android:pathData="M401,301.7v55.7L256,401l-20,-90C236,311 400,302 401,301.7z"
android:fillColor="#C0E5E7"/>
<path
android:pathData="M236,246l20,75c77.2,0 144,-19 145,-19.3l0,-45.7C400.3,256 236,246 236,246z"
android:fillColor="#A4DBDD"/>
<path
android:pathData="M401,210.3V256c-0.7,0 -87.7,0 -165,0l20,-65C333.3,191 400.3,210.1 401,210.3z"
android:fillColor="#C0E5E7"/>
<path
android:pathData="M256,321v75c-77.2,0 -144.3,-38.3 -145,-38.6v-55.7L256,321z"
android:fillColor="#DFF2F3"/>
<path
android:pathData="M256,116v75l-145,19.3v-55.7C111.7,154.3 178.6,116 256,116z"
android:fillColor="#C0E5E7"/>
<path
android:pathData="M256,191v65l-72.5,20L111,256v-45.7C112,210 178.8,191 256,191z"
android:fillColor="#DFF2F3"/>
<path
android:pathData="M111,256c0,0 0,45.7 0,45.7c0.7,0.2 67.7,19.3 145,19.3v-65C256,256 111,256 111,256z"
android:fillColor="#C0E5E7"/>
<path
android:pathData="M416,150.2v211.5l-5,2.9c-2.9,1.7 -72.3,41.3 -155,41.3v-20c65.3,0 123.1,-27.2 140,-36V162c-17,-8.8 -74.9,-36 -140,-36v-20c82.7,0 152.1,39.7 155,41.4L416,150.2z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M256,386v20c-82.7,0 -152.1,-39.7 -155,-41.4l-5,-2.9V150.2l5,-2.9c2.9,-1.7 72.3,-41.4 155,-41.4v20c-65.3,0 -123.1,27.2 -140,36V350C132.9,358.8 190.9,386 256,386z"
android:fillColor="#FFFFFF"/>
</group>
</vector>
================================================
FILE: app/src/dev/res/drawable/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,106l108,-64l0,66l-108,0z"
android:strokeWidth="1"
android:fillColor="#DF2A24"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M60.846,84.634L57.317,78.521L59.482,77.271C61.653,76.018 63.312,76.384 64.459,78.37C65.009,79.322 65.151,80.254 64.887,81.164C64.623,82.074 63.997,82.814 63.011,83.383L60.846,84.634ZM59.341,78.847L61.578,82.722L62.26,82.328C62.857,81.984 63.222,81.534 63.354,80.98C63.487,80.426 63.376,79.841 63.02,79.224C62.683,78.642 62.25,78.281 61.721,78.141C61.191,78.002 60.623,78.107 60.015,78.459L59.341,78.847ZM71.414,78.532L67.748,80.649L64.219,74.536L67.744,72.501L68.391,73.622L66.243,74.863L67.028,76.222L69.027,75.068L69.672,76.185L67.673,77.339L68.48,78.737L70.769,77.416L71.414,78.532ZM74.463,68.622L75.886,75.95L74.326,76.851L68.717,71.94L70.2,71.083L73.931,74.601C74.132,74.792 74.29,74.971 74.406,75.139L74.432,75.125C74.342,74.923 74.265,74.689 74.202,74.422L73.022,69.454L74.463,68.622Z"
android:strokeWidth="1"
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</vector>
================================================
FILE: app/src/dev/res/values/controls.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="uri_scheme">erouska-dev</string>
<string name="key_server_base_url">https://europe-west1-erouska-key-server-dev.cloudfunctions.net/</string>
<string name="verification_server_base_url">https://apiserver-eyrqoibmxa-ew.a.run.app/</string>
<string name="covid_data_server_base_url">https://europe-west1-erouska-key-server-dev.cloudfunctions.net</string>
<string name="fileprovider_authorities" translatable="false">cz.covid19cz.erouska.dev.fileprovider</string>
</resources>
================================================
FILE: app/src/dev/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">eRouška DEV</string>
</resources>
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="cz.covid19cz.erouska">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".App"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".ui.main.MainActivity"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.main.MainActivityOld"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:exported="false"
android:theme="@style/AppTheme.Launchscreen">
</activity>
<service
android:name=".exposurenotifications.service.PushService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Receivers -->
<receiver
android:name=".exposurenotifications.receiver.ExposureNotificationBroadcastReceiver"
android:permission="com.google.android.gms.nearby.exposurenotification.EXPOSURE_CALLBACK"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.exposurenotification.ACTION_EXPOSURE_STATE_UPDATED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove">
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/fileprovider_authorities"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification_normal" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/colorSecondary" />
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
</queries>
</manifest>
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/App.kt
================================================
package cz.covid19cz.erouska
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkManager
import arch.BaseApp
import com.jakewharton.threetenabp.AndroidThreeTen
import cz.covid19cz.erouska.exposurenotifications.Notifications
import cz.covid19cz.erouska.exposurenotifications.worker.DownloadKeysWorker
import dagger.hilt.android.HiltAndroidApp
import java.io.File
import javax.inject.Inject
@HiltAndroidApp
class App : BaseApp(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var notifications: Notifications
override fun onCreate() {
super.onCreate()
AppConfig.fetchRemoteConfig()
AndroidThreeTen.init(this)
notifications.init()
removeObsoleteData()
// Init WorkManager with app context, battery saver prevention
WorkManager.getInstance(this)
//TODO: Remove if eRouška gets resurrected
unscheduleWorkers()
}
private fun unscheduleWorkers(){
WorkManager.getInstance(this).cancelAllWork()
}
override fun getWorkManagerConfiguration() =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
private fun removeObsoleteData() {
val obsoleteDb = File(filesDir.parent + "/databases/android-devices.db")
if (obsoleteDb.exists()) {
obsoleteDb.delete()
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/AppConfig.kt
================================================
package cz.covid19cz.erouska
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings
import cz.covid19cz.erouska.utils.L
object AppConfig {
const val FIREBASE_REGION = "europe-west1"
private val firebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
// Exposure Notifications Settings
val reportTypeWeights
get() = firebaseRemoteConfig.getString("v2_reportTypeWeights").split(";").map { it.toDouble() }
val infectiousnessWeights
get() = firebaseRemoteConfig.getString("v2_infectiousnessWeights").split(";")
.map { it.toDouble() }
val attenuationBucketThresholdDb
get() = firebaseRemoteConfig.getString("v2_attenuationBucketThresholdDb").split(";")
.map { it.toInt() }
val attenuationBucketWeights
get() = firebaseRemoteConfig.getString("v2_attenuationBucketWeights").split(";")
.map { it.toDouble() }
val minimumWindowScore
get() = firebaseRemoteConfig.getDouble("v2_minimumWindowScore")
val daysSinceOnsetToInfectiousness
get() = firebaseRemoteConfig.getString("v2_daysSinceOnsetToInfectiousness").split(";")
.map { it.toInt() }
val diagnosisKeysDataMappingLimitDays
get() = firebaseRemoteConfig.getLong("v2_diagnosisKeysDataMappingLimitDays").toInt()
val dbCleanupDays
get() = firebaseRemoteConfig.getLong("v2_dbCleanupDays").toInt()
val supportEmail
get() = firebaseRemoteConfig.getString("v2_supportEmail")
val reportTypeWhenMissing
get() = firebaseRemoteConfig.getLong("v2_reportTypeWhenMissing").toInt()
val infectiousnessWhenDaysSinceOnsetMissing
get() = firebaseRemoteConfig.getLong("v2_infectiousnessWhenDaysSinceOnsetMissing").toInt()
val shareAppDynamicLink
get() = firebaseRemoteConfig.getString("v2_shareAppDynamicLink")
val minSupportedVersionCodeAndroid
get() = firebaseRemoteConfig.getLong("v2_minSupportedVersionCodeAndroid")
val riskyEncountersTitle
get() = firebaseRemoteConfig.getString("v2_riskyEncountersTitleAn")
val noEncounterHeader
get() = firebaseRemoteConfig.getString("v2_noEncounterHeader")
val noEncounterCardTitle
get() = firebaseRemoteConfig.getString("v2_noEncounterCardTitle")
val noEncounterBody
get() = firebaseRemoteConfig.getString("v2_noEncounterBody")
val encounterUpdateFrequency
get() = String.format(firebaseRemoteConfig.getString("v2_encounterUpdateFrequency"), keyImportPeriodHours)
val exposureUITitle
get() = firebaseRemoteConfig.getString("v2_exposureUITitle")
val symptomsUITitle
get() = firebaseRemoteConfig.getString("v2_symptomsUITitle")
val spreadPreventionUITitle
get() = firebaseRemoteConfig.getString("v2_spreadPreventionUITitle")
val exposureHelpUITitle
get() = firebaseRemoteConfig.getString("v2_exposureHelpUITitle")
val recentExposuresUITitle
get() = firebaseRemoteConfig.getString("v2_recentExposuresUITitle")
val symptomsContentJson
get() = firebaseRemoteConfig.getString("v2_symptomsContentJson")
val preventionContentJson
get() = firebaseRemoteConfig.getString("v2_preventionContentJson")
val exposureHelpContentJson
get() = firebaseRemoteConfig.getString("v2_exposureHelpContentJson")
val encounterWarning
get() = firebaseRemoteConfig.getString("v2_encounterWarning")
val selfCheckerPeriodHours
get() = firebaseRemoteConfig.getLong("v2_selfCheckerPeriodHours")
val keyExportUrl
get() = firebaseRemoteConfig.getString("v2_keyExportUrl")
val keyImportPeriodHours
get() = firebaseRemoteConfig.getLong("v2_keyImportPeriodHours")
val keyImportDataOutdatedHours
get() = firebaseRemoteConfig.getLong("v2_keyImportDataOutdatedHours")
val contactsContentJson
get() = firebaseRemoteConfig.getString("v2_contactsContentJson")
val riskyEncountersWithSymptoms
get() = firebaseRemoteConfig.getString("v2_riskyEncountersWithSymptoms")
val riskyEncountersWithoutSymptoms
get() = firebaseRemoteConfig.getString("v2_riskyEncountersWithoutSymptoms")
val currentMeasuresUrl
get() = firebaseRemoteConfig.getString("v2_currentMeasuresUrl")
val minGmsVersionCode
get() = firebaseRemoteConfig.getLong("v2_minGmsVersionCode")
val conditionsOfUseUrl
get() = firebaseRemoteConfig.getString("v2_conditionsOfUseUrl")
val verificationServerApiKey
get() = firebaseRemoteConfig.getString("v2_verificationServerApiKey")
val showChatBotLink
get() = firebaseRemoteConfig.getBoolean("v2_showChatBotLink")
val handleError500AsInvalidCode
get() = firebaseRemoteConfig.getBoolean("v2_handleError500AsInvalidCode")
val handleError400AsExpiredOrUsedCode
get() = firebaseRemoteConfig.getBoolean("v2_handleError400AsExpiredOrUsedCode")
val keyExportNonTravellerUrls
get() = firebaseRemoteConfig.getString("v2_keyExportNonTravellerUrls")
val keyExportEuTravellerUrls
get() = firebaseRemoteConfig.getString("v2_keyExportEuTravellerUrls")
val recentExposureNotificationTitle
get() = firebaseRemoteConfig.getString("v2_recentExposureNotificationTitle")
val updateNewsOnRequest
get() = firebaseRemoteConfig.getBoolean("v2_updateNewsOnRequest")
val efgsDays
get() = firebaseRemoteConfig.getLong("v2_efgsDays").toInt()
val efgsSupportedCountries
get() = firebaseRemoteConfig.getString("v2_efgsCountries")
val efgsVisitedCountries
get() = firebaseRemoteConfig.getString("v2_efgsVisitedCountries").split(";")
val efgsReportType
get() = firebaseRemoteConfig.getString("v2_efgsReportType")
val efgsConsentToFederation
get() = firebaseRemoteConfig.getBoolean("v2_efgsConsentToFederation")
val efgsTravellerDefault
get() = firebaseRemoteConfig.getBoolean("v2_efgsTravellerDefault")
val howItWorksUITitle
get() = firebaseRemoteConfig.getString("v2_howItWorksUITitle")
val howItWorksEvalContent
get() = firebaseRemoteConfig.getString("v2_howItWorksEvalContent")
val helpJson
get() = firebaseRemoteConfig.getString("v2_helpJson")
val validationTokenExpirationLeewayMinutes
get() = firebaseRemoteConfig.getLong("v2_validationTokenExpirationLeewayMinutes")
val ragnarokHeadline
get() = firebaseRemoteConfig.getString("v2_ragnarokHeadline")
val ragnarokBody
get() = firebaseRemoteConfig.getString("v2_ragnarokBody")
val ragnarokMoreInfo
get() = firebaseRemoteConfig.getString("v2_ragnarokMoreInfo")
init {
val configSettings: FirebaseRemoteConfigSettings = FirebaseRemoteConfigSettings.Builder()
.setMinimumFetchIntervalInSeconds(if (BuildConfig.DEBUG) 0 else 3600)
.build()
firebaseRemoteConfig.setConfigSettingsAsync(configSettings)
firebaseRemoteConfig.setDefaultsAsync(R.xml.remote_config_defaults).addOnCompleteListener {
print()
}
}
fun fetchRemoteConfig() {
firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener { task ->
if (task.isSuccessful) {
val updated = task.result
L.d("Config params updated: $updated")
print()
} else {
L.e("Config params update failed")
task.exception?.printStackTrace()
}
}
}
private fun print() {
for (item in firebaseRemoteConfig.all) {
L.d("${item.key}: ${item.value.asString()}")
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/DI.kt
================================================
package cz.covid19cz.erouska
import android.content.Context
import androidx.room.Room
import com.google.android.gms.nearby.Nearby
import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
import cz.covid19cz.erouska.db.DailySummariesDb
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideExposureNotificationClient(@ApplicationContext context: Context): ExposureNotificationClient {
return Nearby.getExposureNotificationClient(context)
}
@Provides
@Singleton
fun provideDailySummariesDb(@ApplicationContext context: Context): DailySummariesDb {
return Room.databaseBuilder(
context.applicationContext,
DailySummariesDb::class.java, "daily_summaries"
).build()
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/db/DailySummariesDb.kt
================================================
package cz.covid19cz.erouska.db
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(version = 1, entities = [DailySummaryEntity::class], exportSchema = false)
abstract class DailySummariesDb : RoomDatabase(){
abstract fun dao(): DailySummaryDao
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/db/DailySummaryDao.kt
================================================
package cz.covid19cz.erouska.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import cz.covid19cz.erouska.AppConfig
import java.util.concurrent.TimeUnit
@Dao
interface DailySummaryDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(entity : List<DailySummaryEntity>)
@Query("SELECT * FROM daily_summaries ORDER BY days_since_epoch DESC LIMIT 1")
suspend fun getLatest() : List<DailySummaryEntity>
@Query("SELECT * FROM daily_summaries WHERE notified == 1 ORDER BY days_since_epoch DESC LIMIT 1")
suspend fun getLastNotified() : List<DailySummaryEntity>
@Query("SELECT * FROM daily_summaries ORDER BY days_since_epoch DESC")
suspend fun getAllByExposureDate() : List<DailySummaryEntity>
@Query("SELECT * FROM daily_summaries ORDER BY import_timestamp DESC, days_since_epoch DESC")
suspend fun getAllByImportDate() : List<DailySummaryEntity>
@Query("UPDATE daily_summaries SET notified = 1")
suspend fun markAsNotified()
@Query("UPDATE daily_summaries SET accepted = 1")
suspend fun markAsAccepted()
@Query("DELETE FROM daily_summaries WHERE days_since_epoch < :beforeDaysSinceEpoch")
suspend fun deleteOld(beforeDaysSinceEpoch : Long = TimeUnit.MILLISECONDS.toDays (System.currentTimeMillis()) - AppConfig.dbCleanupDays)
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/db/DailySummaryEntity.kt
================================================
package cz.covid19cz.erouska.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import cz.covid19cz.erouska.ext.daysSinceEpochToDateString
@Entity(tableName = "daily_summaries")
data class DailySummaryEntity(
@ColumnInfo(name = "days_since_epoch")
@PrimaryKey val daysSinceEpoch: Int,
@ColumnInfo(name = "maximum_score")
val maximumScore: Double,
@ColumnInfo(name = "score_sum")
val scoreSum: Double,
@ColumnInfo(name = "weightened_duration_sum")
val weightenedDurationSum: Double,
@ColumnInfo(name = "import_timestamp")
val importTimestamp: Long,
@ColumnInfo(name = "notified")
val notified: Boolean,
@ColumnInfo(name = "accepted")
val accepted: Boolean
){
fun getDateString() : String{
return daysSinceEpoch.daysSinceEpochToDateString()
}
fun getLongDateString() : String{
return daysSinceEpoch.daysSinceEpochToDateString("d. MMMM yyyy")
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/db/SharedPrefsRepository.kt
================================================
package cz.covid19cz.erouska.db
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import arch.livedata.SafeMutableLiveData
import com.auth0.android.jwt.JWT
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.ext.timestampToDate
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SharedPrefsRepository @Inject constructor(@ApplicationContext c: Context) {
companion object {
const val LAST_KEY_IMPORT = "preference.last_import"
const val LAST_KEY_IMPORT_TIME = "preference.last_import_time"
const val LAST_SHOWN_EXPOSURE_INFO = "lastShownExposureInfo"
const val EXPOSURE_NOTIFICATIONS_ENABLED = "exposureNotificationsEnabled"
const val LAST_SET_DIAGNOSIS_KEYS_DATA_MAPPING = "lastSetDiagnosisKeysDataMapping"
const val EFGS_INTRODUCED = "efgsIntroduced"
const val APP_OPEN_TIMESTAMP = "lastTimeAppOpened"
const val SUPPRESS_UPDATE_SCREENS = "suppressUpdateScreens"
const val REPORT_TYPE_WEIGHTS = "reportTypeWeights"
const val INFECTIOUSNESS_WEIGHTS = "infectiousnessWeights"
const val ATTENUATION_BUCKET_THRESHOLD_DB = "attenuationBucketThresholdDb"
const val ATTENUATION_BUCKET_WEIGHTS = "attenuationBucketWeights"
const val MINIMUM_WINDOW_SCORE = "minimumWindowScore"
const val LAST_STATS_UPDATE = "lastStatsUpdate"
const val LAST_METRICS_UPDATE = "lastMetricsUpdate"
// stats
const val TESTS_TOTAL = "testsTotal"
const val TESTS_INCREASE = "testsIncrease"
const val TESTS_INCREASE_DATE = "testsIncreaseDate"
const val ANTIGEN_TESTS_TOTAL = "antigenTestsTotal"
const val ANTIGEN_TESTS_INCREASE = "antigenTestsIncrease"
const val ANTIGEN_TESTS_INCREASE_DATE = "antigenTestsIncreaseDate"
const val VACCINATIONS_TOTAL = "vaccinationsTotal"
const val VACCINATIONS_INCREASE = "vaccinationsIncrease"
const val VACCINATIONS_INCREASE_DATE = "vaccinationsIncreaseDate"
const val DAILY_DOSES_DATE = "dailyDosesDate"
const val FIRST_DOSE_TOTAL = "firstDoseTotal"
const val FIRST_DOSE_INCREASE = "firstDoseIncrease"
const val SECOND_DOSE_TOTAL = "secondDoseTotal"
const val SECOND_DOSE_INCREASE = "secondDoseIncrease"
const val CONFIRMED_CASES_TOTAL = "confirmedCasesTotal"
const val CONFIRMED_CASES_INCREASE = "confirmedCasesIncrease"
const val CONFIRMED_CASES_INCREASE_DATE = "confirmedCasesIncreaseDate"
const val ACTIVE_CASES_TOTAL = "activeCasesTotal"
const val CURED_TOTAL = "curedTotal"
const val DECEASED_TOTAL = "deceasedTotal"
const val CURRENTLY_HOSPITALIZED_TOTAL = "currentlyHospitalizedTotal"
// metrics
const val ACTIVATIONS_TOTAL = "activationsTotal"
const val ACTIVATIONS_YESTERDAY = "activationsIncrease"
const val KEY_PUBLISHERS_TOTAL = "keyPublishersTotal"
const val KEY_PUBLISHERS_YESTERDAY = "keyPublishersYesterday"
const val NOTIFICATIONS_TOTAL = "notificationsTotal"
const val NOTIFICATIONS_YESTERDAY = "notificationsTotal"
const val TRAVELLER = "traveller"
const val CONSENT_TO_FEDERATION = "consentToFederation"
const val PUSH_TOKEN_REGISTERED = "pushTokenRegistered"
const val PUSH_TOPIC_REGISTERED = "pushTopicRegistered"
const val HOW_IT_WORKS_SHOWN = "howItWorksShown"
const val LAST_DATA_SENT_TIME = "lastDataSentTime"
const val VALIDATION_CODE = "validationCode"
const val VALIDATION_TOKEN = "validationToken"
const val SYMPTOM_DATE = "symptomDate"
}
private val prefs: SharedPreferences = c.getSharedPreferences("prefs", MODE_PRIVATE)
val lastKeyImportLive = SafeMutableLiveData(getLastKeyImport())
fun lastKeyExportFileName(indexUrl: String): String {
return prefs.getString(LAST_KEY_IMPORT + indexUrl, "") ?: ""
}
fun setLastKeyExportFileName(indexUrl: String, filename: String) {
prefs.edit().putString(LAST_KEY_IMPORT + indexUrl, filename).apply()
}
fun setLastKeyImport() {
val timestamp = System.currentTimeMillis()
prefs.edit().putLong(LAST_KEY_IMPORT_TIME, timestamp).apply()
lastKeyImportLive.postValue(timestamp)
}
fun getLastKeyImport(): Long {
return prefs.getLong(LAST_KEY_IMPORT_TIME, 0L)
}
fun setLastSetDiagnosisKeysDataMapping() {
prefs.edit().putLong(LAST_SET_DIAGNOSIS_KEYS_DATA_MAPPING, System.currentTimeMillis())
.apply()
}
fun getLastSetDiagnosisKeysDataMapping(): Long {
return prefs.getLong(LAST_SET_DIAGNOSIS_KEYS_DATA_MAPPING, 0L)
}
fun setLastShownExposureInfo(daysSinceEpoch: Int) {
prefs.edit().putInt(LAST_SHOWN_EXPOSURE_INFO, daysSinceEpoch).apply()
}
fun getLastShownExposureInfo(): Int {
return prefs.getInt(LAST_SHOWN_EXPOSURE_INFO, 0)
}
fun isTraveller(): Boolean {
return prefs.getBoolean(TRAVELLER, true)
}
fun setTraveller(traveller: Boolean) {
prefs.edit().putBoolean(TRAVELLER, traveller).apply()
}
fun isConsentToFederation(): Boolean {
return prefs.getBoolean(CONSENT_TO_FEDERATION, false)
}
fun setConsentToFederation(consentToFederation: Boolean) {
prefs.edit().putBoolean(CONSENT_TO_FEDERATION, consentToFederation).apply()
}
fun isPushTokenRegistered(): Boolean {
return prefs.getBoolean(PUSH_TOKEN_REGISTERED, false)
}
fun setPushTokenRegistered() {
prefs.edit().putBoolean(PUSH_TOKEN_REGISTERED, true).apply()
}
fun isPushTopicRegistered(): Boolean {
return prefs.getBoolean(PUSH_TOPIC_REGISTERED, false)
}
fun setPushTopicRegistered() {
prefs.edit().putBoolean(PUSH_TOPIC_REGISTERED, true).apply()
}
fun hasOutdatedKeyData(): Boolean {
val lastTimestamp = getLastKeyImport()
return lastTimestamp != 0L && (System.currentTimeMillis() - lastTimestamp) / (1000 * 60 * 60) > AppConfig.keyImportDataOutdatedHours
}
fun clearLastKeyExportFileName() {
prefs.edit().remove(LAST_KEY_IMPORT).apply()
}
fun clearLastKeyImportTime() {
prefs.edit().remove(LAST_KEY_IMPORT_TIME).apply()
}
fun isExposureNotificationsEnabled(): Boolean {
return prefs.getBoolean(EXPOSURE_NOTIFICATIONS_ENABLED, false)
}
fun setExposureNotificationsEnabled(enabled: Boolean) {
prefs.edit().putBoolean(EXPOSURE_NOTIFICATIONS_ENABLED, enabled).apply()
}
fun setAppVisitedTimestamp() {
prefs.edit().putLong(APP_OPEN_TIMESTAMP, System.currentTimeMillis()).apply()
}
fun getLastTimeAppVisited(): Long {
return prefs.getLong(APP_OPEN_TIMESTAMP, 0L)
}
fun setSuppressUpdateScreens(suppress: Boolean) {
prefs.edit().putBoolean(SUPPRESS_UPDATE_SCREENS, suppress).apply()
}
fun shouldSuppressUpdateScreens(): Boolean {
return prefs.getBoolean(SUPPRESS_UPDATE_SCREENS, false)
}
fun clearCustomConfig() {
prefs.edit().apply {
remove(REPORT_TYPE_WEIGHTS)
remove(ATTENUATION_BUCKET_THRESHOLD_DB)
remove(ATTENUATION_BUCKET_WEIGHTS)
remove(MINIMUM_WINDOW_SCORE)
}.apply()
}
fun setReportTypeWeights(value: String) {
prefs.edit().putString(REPORT_TYPE_WEIGHTS, value).apply()
}
fun setInfectiousnessWeights(value: String) {
prefs.edit().putString(INFECTIOUSNESS_WEIGHTS, value).apply()
}
fun setAttenuationBucketThresholdDb(value: String) {
prefs.edit().putString(ATTENUATION_BUCKET_THRESHOLD_DB, value).apply()
}
fun setAttenuationBucketWeights(value: String) {
prefs.edit().putString(ATTENUATION_BUCKET_WEIGHTS, value).apply()
}
fun setMinimumWindowScore(value: String) {
prefs.edit().putString(MINIMUM_WINDOW_SCORE, value).apply()
}
fun getReportTypeWeights(): List<Double>? {
return prefs.getString(REPORT_TYPE_WEIGHTS, null)?.let {
it.split(";").mapNotNull { it.toDoubleOrNull() }
}
}
fun getInfectiousnessWeights(): List<Double>? {
return prefs.getString(INFECTIOUSNESS_WEIGHTS, null)?.let {
it.split(";").mapNotNull { it.toDoubleOrNull() }
}
}
fun getAttenuationBucketThresholdDb(): List<Int>? {
return prefs.getString(ATTENUATION_BUCKET_THRESHOLD_DB, null)?.let {
it.split(";").mapNotNull { it.toIntOrNull() }
}
}
fun getAttenuationBucketWeights(): List<Double>? {
return prefs.getString(ATTENUATION_BUCKET_WEIGHTS, null)?.let {
it.split(";").mapNotNull { it.toDoubleOrNull() }
}
}
fun getMinimumWindowScore(): Double? {
return prefs.getString(MINIMUM_WINDOW_SCORE, null)?.toDoubleOrNull()
}
fun getLastStatsUpdate(): Long {
return prefs.getLong(LAST_STATS_UPDATE, 0)
}
fun setLastStatsUpdate(modified: Long) {
return prefs.edit().putLong(LAST_STATS_UPDATE, modified).apply()
}
fun getLastMetricsUpdate(): Long {
return prefs.getLong(LAST_METRICS_UPDATE, 0)
}
fun setLastMetricsUpdate(modified: Long) {
return prefs.edit().putLong(LAST_METRICS_UPDATE, modified).apply()
}
fun getTestsTotal(): Int {
return prefs.getInt(TESTS_TOTAL, 0)
}
fun setTestsTotal(value: Int) {
return prefs.edit().putInt(TESTS_TOTAL, value).apply()
}
fun getTestsIncrease(): Int {
return prefs.getInt(TESTS_INCREASE, 0)
}
fun setTestsIncrease(value: Int) {
return prefs.edit().putInt(TESTS_INCREASE, value).apply()
}
fun getTestsIncreaseDate(): Long {
return prefs.getLong(TESTS_INCREASE_DATE, 0)
}
fun setTestsIncreaseDate(value: Long) {
return prefs.edit().putLong(TESTS_INCREASE_DATE, value).apply()
}
fun getAntigenTestsTotal(): Int {
return prefs.getInt(ANTIGEN_TESTS_TOTAL, 0)
}
fun setAntigenTestsTotal(value: Int) {
return prefs.edit().putInt(ANTIGEN_TESTS_TOTAL, value).apply()
}
fun getAntigenTestsIncrease(): Int {
return prefs.getInt(ANTIGEN_TESTS_INCREASE, 0)
}
fun setAntigenTestsIncrease(value: Int) {
return prefs.edit().putInt(ANTIGEN_TESTS_INCREASE, value).apply()
}
fun getAntigenTestsIncreaseDate(): Long {
return prefs.getLong(ANTIGEN_TESTS_INCREASE_DATE, 0)
}
fun setAntigenTestsIncreaseDate(value: Long) {
return prefs.edit().putLong(ANTIGEN_TESTS_INCREASE_DATE, value).apply()
}
fun getVaccinationsTotal(): Int {
return prefs.getInt(VACCINATIONS_TOTAL, 0)
}
fun setVaccinationsTotal(value: Int) {
return prefs.edit().putInt(VACCINATIONS_TOTAL, value).apply()
}
fun getVaccinationsIncrease(): Int {
return prefs.getInt(VACCINATIONS_INCREASE, 0)
}
fun setVaccinationsIncrease(value: Int) {
return prefs.edit().putInt(VACCINATIONS_INCREASE, value).apply()
}
fun getVaccinationsIncreaseDate(): Long {
return prefs.getLong(VACCINATIONS_INCREASE_DATE, 0)
}
fun setVaccinationsIncreaseDate(value: Long) {
return prefs.edit().putLong(VACCINATIONS_INCREASE_DATE, value).apply()
}
fun getFirstDoseTotal(): Int {
return prefs.getInt(FIRST_DOSE_TOTAL, 0)
}
fun setFirstDoseTotal(value: Int) {
return prefs.edit().putInt(FIRST_DOSE_TOTAL, value).apply()
}
fun getFirstDoseIncrease(): Int {
return prefs.getInt(FIRST_DOSE_INCREASE, 0)
}
fun setFirstDoseIncrease(value: Int) {
return prefs.edit().putInt(FIRST_DOSE_INCREASE, value).apply()
}
fun getSecondDoseTotal(): Int {
return prefs.getInt(SECOND_DOSE_TOTAL, 0)
}
fun setSecondDoseTotal(value: Int) {
return prefs.edit().putInt(SECOND_DOSE_TOTAL, value).apply()
}
fun getSecondDoseIncrease(): Int {
return prefs.getInt(SECOND_DOSE_INCREASE, 0)
}
fun setSecondDoseIncrease(value: Int) {
return prefs.edit().putInt(SECOND_DOSE_INCREASE, value).apply()
}
fun getDailyDosesDate(): Long {
return prefs.getLong(DAILY_DOSES_DATE, 0)
}
fun setDailyDosesDate(value: Long) {
return prefs.edit().putLong(DAILY_DOSES_DATE, value).apply()
}
fun getConfirmedCasesTotal(): Int {
return prefs.getInt(CONFIRMED_CASES_TOTAL, 0)
}
fun setConfirmedCasesTotal(value: Int) {
return prefs.edit().putInt(CONFIRMED_CASES_TOTAL, value).apply()
}
fun getConfirmedCasesIncrease(): Int {
return prefs.getInt(CONFIRMED_CASES_INCREASE, 0)
}
fun setConfirmedCasesIncrease(value: Int) {
return prefs.edit().putInt(CONFIRMED_CASES_INCREASE, value).apply()
}
fun getConfirmedCasesIncreaseDate(): Long {
return prefs.getLong(CONFIRMED_CASES_INCREASE_DATE, 0)
}
fun setConfirmedCasesIncreaseDate(value: Long) {
return prefs.edit().putLong(CONFIRMED_CASES_INCREASE_DATE, value).apply()
}
fun getActiveCasesTotal(): Int {
return prefs.getInt(ACTIVE_CASES_TOTAL, 0)
}
fun setActiveCasesTotal(value: Int) {
return prefs.edit().putInt(ACTIVE_CASES_TOTAL, value).apply()
}
fun getCuredTotal(): Int {
return prefs.getInt(CURED_TOTAL, 0)
}
fun setCuredTotal(value: Int) {
return prefs.edit().putInt(CURED_TOTAL, value).apply()
}
fun getDeceasedTotal(): Int {
return prefs.getInt(DECEASED_TOTAL, 0)
}
fun setDeceasedTotal(value: Int) {
return prefs.edit().putInt(DECEASED_TOTAL, value).apply()
}
fun getCurrentlyHospitalizedTotal(): Int {
return prefs.getInt(CURRENTLY_HOSPITALIZED_TOTAL, 0)
}
fun setCurrentlyHospitalizedTotal(value: Int) {
return prefs.edit().putInt(CURRENTLY_HOSPITALIZED_TOTAL, value).apply()
}
fun getActivationsTotal(): Int {
return prefs.getInt(ACTIVATIONS_TOTAL, 0)
}
fun setActivationsTotal(value: Int) {
return prefs.edit().putInt(ACTIVATIONS_TOTAL, value).apply()
}
fun getActivationsYesterday(): Int {
return prefs.getInt(ACTIVATIONS_YESTERDAY, 0)
}
fun setActivationsYesterday(value: Int) {
return prefs.edit().putInt(ACTIVATIONS_YESTERDAY, value).apply()
}
fun getKeyPublishersTotal(): Int {
return prefs.getInt(KEY_PUBLISHERS_TOTAL, 0)
}
fun setKeyPublishersTotal(value: Int) {
return prefs.edit().putInt(KEY_PUBLISHERS_TOTAL, value).apply()
}
fun getKeyPublishersYesterday(): Int {
return prefs.getInt(KEY_PUBLISHERS_YESTERDAY, 0)
}
fun setKeyPublishersYesterday(value: Int) {
return prefs.edit().putInt(KEY_PUBLISHERS_YESTERDAY, value).apply()
}
fun getNotificationsTotal(): Int {
return prefs.getInt(NOTIFICATIONS_TOTAL, 0)
}
fun setNotificationsTotal(value: Int) {
return prefs.edit().putInt(NOTIFICATIONS_TOTAL, value).apply()
}
fun getNotificationsYesterday(): Int {
return prefs.getInt(NOTIFICATIONS_YESTERDAY, 0)
}
fun setNotificationsYesterday(value: Int) {
return prefs.edit().putInt(NOTIFICATIONS_YESTERDAY, value).apply()
}
fun wasEFGSIntroduced(): Boolean {
return prefs.getBoolean(EFGS_INTRODUCED, false)
}
fun setEFGSIntroduced(value: Boolean) {
return prefs.edit().putBoolean(EFGS_INTRODUCED, value).apply()
}
fun wasHowItWorksShown(): Boolean {
return prefs.getBoolean(HOW_IT_WORKS_SHOWN, false)
}
fun setHowItWorksShown() {
prefs.edit().putBoolean(HOW_IT_WORKS_SHOWN, true).apply()
}
fun setVerificationData(code: String, token: String) {
prefs.edit().putString(VALIDATION_CODE, code)
.putString(VALIDATION_TOKEN, token).apply()
}
fun getVerificationCode(): String? {
return prefs.getString(VALIDATION_CODE, null)
}
fun getVerificationToken(): String? {
return prefs.getString(VALIDATION_TOKEN, null)
}
fun deletePublishKeysTemporaryData() {
prefs.edit().remove(VALIDATION_CODE)
.remove(VALIDATION_TOKEN)
.remove(CONSENT_TO_FEDERATION)
.remove(SYMPTOM_DATE)
.apply()
}
fun isCodeValidated(code: String?): Boolean {
val savedCode = prefs.getString(VALIDATION_CODE, null)
return if (savedCode == code) {
hasValidationToken(useLeeway = true)
} else {
false
}
}
fun hasValidationToken(useLeeway: Boolean): Boolean {
val token = prefs.getString(VALIDATION_TOKEN, null)
return if (token != null) {
//Leeway is time, which is subtracted from expiration, to be sure, user has enough time to complete the process before expiration
!JWT(token).isExpired(if (useLeeway) AppConfig.validationTokenExpirationLeewayMinutes * 60 else 60)
} else {
false
}
}
fun setSymptomDate(timestamp: Long?) {
if (timestamp == null) {
prefs.edit().remove(SYMPTOM_DATE).apply()
} else {
prefs.edit().putLong(SYMPTOM_DATE, timestamp).apply()
}
}
fun getSymptomOnsetInterval(): Long? {
return prefs.getLong(SYMPTOM_DATE, 0L).let {
//Unix timestamp / 600
if (it != 0L) TimeUnit.SECONDS.convert(it, TimeUnit.MILLISECONDS) / 600 else null
}
}
fun setLastDataSentDate() {
prefs.edit().putLong(LAST_DATA_SENT_TIME, System.currentTimeMillis()).apply()
}
fun getLastDataSentDateString(): String? {
return prefs.getLong(LAST_DATA_SENT_TIME, 0L).let {
if (it != 0L) it.timestampToDate() else null
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/ExposureCryptoTools.kt
================================================
package cz.covid19cz.erouska.exposurenotifications
import android.util.Base64
import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
import cz.covid19cz.erouska.utils.L
import java.nio.charset.StandardCharsets
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.ArrayList
import kotlin.random.Random
@Singleton
class ExposureCryptoTools @Inject constructor() {
fun hashedKeys(keys: List<TemporaryExposureKey>, hmacKey: String): String {
val cleartextSegments = ArrayList<HashedKeyData>()
for (k in keys) {
val base64key = k.keyData.encodeBase64()
cleartextSegments.add(
HashedKeyData(
base64key, String.format(
Locale.ENGLISH,
"%s.%d.%d",
base64key,
k.rollingStartIntervalNumber,
k.rollingPeriod
)
)
)
}
val cleartext = cleartextSegments.sortedBy { it.base64key }.joinToString(",") { it.data }
L.i("Hashing ${keys.size} keys")
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(hmacKey.decodeBase64(), "HmacSHA256"))
return mac.doFinal(cleartext.toByteArray(StandardCharsets.UTF_8)).encodeBase64()
}
fun newHmacKey(): String {
val bytes = ByteArray(16)
Random.nextBytes(bytes)
return bytes.encodeBase64()
}
private class HashedKeyData(val base64key: String, val data: String)
}
fun ByteArray.encodeBase64(): String {
return Base64.encodeToString(this, Base64.NO_WRAP)
}
fun String.decodeBase64(): ByteArray {
return Base64.decode(this, Base64.NO_WRAP)
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/ExposureNotificationsErrorHandling.kt
================================================
package cz.covid19cz.erouska.exposurenotifications
import android.content.Context
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import com.google.android.gms.nearby.exposurenotification.ExposureNotificationStatusCodes
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.ui.dashboard.event.GmsApiErrorEvent
import cz.covid19cz.erouska.utils.DeviceInfo
import cz.covid19cz.erouska.utils.SupportEmailGenerator
import java.util.regex.Matcher
import java.util.regex.Pattern
import javax.inject.Inject
import javax.inject.Singleton
/**
* This class is heavily inspired by Swiss COVID app:
* https://github.com/DP-3T/dp3t-app-android-ch/blob/1cc2f7cef39206a09ad74ddbdcce69dd7af7d03b/app/src/main/java/ch/admin/bag/dp3t/util/ENExceptionHelper.java
*/
@Singleton
class ExposureNotificationsErrorHandling @Inject constructor(
private val deviceInfo: DeviceInfo,
private val supportEmailGenerator: SupportEmailGenerator
) {
companion object {
const val REQUEST_GMS_ERROR_RESOLUTION = 42
private const val ERROR_CODE_UNKNOWN = -2
}
private val CONNECTION_RESULT_PATTERN: Pattern =
Pattern.compile("ConnectionResult\\{[^}]*statusCode=[a-zA-Z0-9_]+\\((\\d+)\\)")
fun handle(gmsApiErrorEvent: GmsApiErrorEvent, fragment: Fragment, screen: String) {
if (gmsApiErrorEvent.throwable is ApiException) {
try {
fragment.startIntentSenderForResult(
gmsApiErrorEvent.throwable.status.resolution?.intentSender,
REQUEST_GMS_ERROR_RESOLUTION,
null,
0,
0,
0,
null
)
} catch (t: Throwable) {
showErrorDialog(fragment, gmsApiErrorEvent.throwable, screen)
}
} else {
showErrorDialog(fragment, gmsApiErrorEvent.throwable, screen)
}
}
private fun showErrorDialog(fragment: Fragment, throwable: Throwable, screen: String) {
val errorMessage = getErrorMessage(throwable, fragment.requireContext())
AlertDialog.Builder(fragment.requireContext())
.setTitle(fragment.getString(R.string.activation_error))
.setMessage(
fragment.getString(
R.string.activation_error_reason,
errorMessage
)
)
.setPositiveButton(R.string.support_request_button) { _, _ ->
supportEmailGenerator.sendSupportEmail(
fragment.requireActivity(),
fragment.lifecycleScope,
errorCode = errorMessage,
isError = true,
screenOrigin = screen
)
}.setNegativeButton(R.string.send_data_close) { _, _ -> }.show()
}
private fun getErrorMessage(exception: Throwable, context: Context): String {
var errorDetailMessage: String? = null
var attachExceptionMessage = true
if (exception is ApiException) {
val status = exception.status
if (status.statusCode == CommonStatusCodes.API_NOT_CONNECTED && status.statusMessage != null) {
when (val connectionStatusCode: Int = getConnectionStatusCode(status)) {
ExposureNotificationStatusCodes.FAILED_NOT_SUPPORTED -> if (!deviceInfo.supportsBLE()) {
errorDetailMessage =
context.getString(R.string.activation_error_reason_bluetooth_le)
attachExceptionMessage = false
} else if (!deviceInfo.isUserDeviceOwner()) {
errorDetailMessage =
context.getString(R.string.activation_error_reason_admin)
attachExceptionMessage = false
} else if (!deviceInfo.supportsMultiAds()) {
errorDetailMessage =
context.getString(R.string.activation_error_reason_bluetooth_ad)
} else {
errorDetailMessage =
context.getString(R.string.activation_error_reason_en_api)
}
ExposureNotificationStatusCodes.FAILED_UNAUTHORIZED -> {
errorDetailMessage =
context.getString(R.string.activation_error_reason_unauthorized)
}
else -> errorDetailMessage =
ExposureNotificationStatusCodes.getStatusCodeString(connectionStatusCode)
}
}
}
return if (errorDetailMessage != null) {
if (attachExceptionMessage) {
"$errorDetailMessage\n${exception.message}"
} else {
errorDetailMessage
}
} else {
exception.message ?: ""
}
}
private fun getConnectionStatusCode(status: Status): Int {
val statusMessage = status.statusMessage
if (statusMessage != null) {
val matcher: Matcher = CONNECTION_RESULT_PATTERN.matcher(statusMessage)
if (matcher.find()) {
val connectionStatusCode: String? = matcher.group(1)
return connectionStatusCode?.toInt() ?: ERROR_CODE_UNKNOWN
}
}
return ERROR_CODE_UNKNOWN
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/ExposureNotificationsRepository.kt
================================================
package cz.covid19cz.erouska.exposurenotifications
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.google.android.gms.nearby.exposurenotification.*
import com.google.gson.Gson
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.db.DailySummariesDb
import cz.covid19cz.erouska.db.DailySummaryEntity
import cz.covid19cz.erouska.db.SharedPrefsRepository
import cz.covid19cz.erouska.exposurenotifications.worker.SelfCheckerWorker
import cz.covid19cz.erouska.net.ExposureServerRepository
import cz.covid19cz.erouska.net.FirebaseFunctionsRepository
import cz.covid19cz.erouska.net.model.*
import cz.covid19cz.erouska.ui.verification.InvalidTokenException
import cz.covid19cz.erouska.ui.verification.NoKeysException
import cz.covid19cz.erouska.ui.verification.ReportExposureException
import cz.covid19cz.erouska.ui.verification.VerifyException
import cz.covid19cz.erouska.utils.L
import dagger.hilt.android.qualifiers.ApplicationContext
import org.threeten.bp.LocalDate
import retrofit2.HttpException
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@Singleton
class ExposureNotificationsRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val client: ExposureNotificationClient,
private val server: ExposureServerRepository,
private val cryptoTools: ExposureCryptoTools,
private val prefs: SharedPrefsRepository,
private val firebaseFunctionsRepository: FirebaseFunctionsRepository,
private val db: DailySummariesDb,
private val notifications: Notifications
) {
suspend fun start() = suspendCoroutine<Void> { cont ->
client.start()
.addOnSuccessListener {
prefs.setExposureNotificationsEnabled(true)
cont.resume(it)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun stop() = suspendCoroutine<Void> { cont ->
client.stop()
.addOnSuccessListener {
prefs.setExposureNotificationsEnabled(false)
cont.resume(it)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun isEnabled(): Boolean = suspendCoroutine { cont ->
client.isEnabled
.addOnSuccessListener {
cont.resume(it)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun getStatus(): Set<ExposureNotificationStatus> = suspendCoroutine { cont ->
client.status
.addOnSuccessListener {
cont.resume(it)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun provideDiagnosisKeys(
keyList: List<DownloadedKeys>
): Boolean = suspendCoroutine { cont ->
setDiagnosisKeysMapping()
val filesToImport = mutableListOf<File>()
keyList.forEach { keys ->
if (keys.isValid()) {
if (keys.files.isNotEmpty()) {
L.i("Importing keys ${keys.indexUrl}")
filesToImport.addAll(keys.files)
} else {
L.i("Import skipped (no new data) ${keys.indexUrl}")
}
} else {
L.i("Import skipped (invalid data) ${keys.indexUrl}")
}
}
if (filesToImport.isEmpty()) {
L.i("All skipped (empty)")
prefs.setLastKeyImport()
} else {
client.provideDiagnosisKeys(filesToImport)
.addOnSuccessListener {
L.i("Import success of ${filesToImport.size} files")
prefs.setLastKeyImport()
keyList.forEach { keys ->
if (keys.isValid() && keys.files.isNotEmpty()) {
L.d("Last successful import for ${keys.indexUrl} is ${keys.getLastUrl()}")
prefs.setLastKeyExportFileName(keys.indexUrl, keys.getLastUrl())
}
}
cont.resume(true)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
}
private fun setDiagnosisKeysMapping() {
if (System.currentTimeMillis() - prefs.getLastSetDiagnosisKeysDataMapping() > AppConfig.diagnosisKeysDataMappingLimitDays * 24 * 60 * 60 * 1000) {
val daysList = AppConfig.daysSinceOnsetToInfectiousness
val daysToInfectiousness = mutableMapOf<Int, Int>()
for (i in -14..14) {
daysToInfectiousness[i] = daysList[i + 14]
}
val mapping = DiagnosisKeysDataMapping.DiagnosisKeysDataMappingBuilder()
.setDaysSinceOnsetToInfectiousness(daysToInfectiousness)
.setInfectiousnessWhenDaysSinceOnsetMissing(AppConfig.infectiousnessWhenDaysSinceOnsetMissing)
.setReportTypeWhenMissing(AppConfig.reportTypeWhenMissing)
.build()
try {
client.setDiagnosisKeysDataMapping(mapping)
} catch (t: Throwable) {
L.e(t)
} finally {
prefs.setLastSetDiagnosisKeysDataMapping()
}
}
}
suspend fun getDailySummariesFromApi(filter: Boolean = true): List<DailySummary> =
suspendCoroutine { cont ->
val reportTypeWeights = prefs.getReportTypeWeights() ?: AppConfig.reportTypeWeights
val attenuationBucketThresholdDb =
prefs.getAttenuationBucketThresholdDb()
?: AppConfig.attenuationBucketThresholdDb
val attenuationBucketWeights =
prefs.getAttenuationBucketWeights() ?: AppConfig.attenuationBucketWeights
val infectiousnessWeights =
prefs.getInfectiousnessWeights() ?: AppConfig.infectiousnessWeights
client.getDailySummaries(
DailySummariesConfig.DailySummariesConfigBuilder().apply {
setReportTypeWeight(ReportType.CONFIRMED_TEST, reportTypeWeights[1])
setReportTypeWeight(
ReportType.CONFIRMED_CLINICAL_DIAGNOSIS,
reportTypeWeights[2]
)
setReportTypeWeight(ReportType.SELF_REPORT, reportTypeWeights[3])
setReportTypeWeight(ReportType.RECURSIVE, reportTypeWeights[4])
setInfectiousnessWeight(Infectiousness.STANDARD, infectiousnessWeights[1])
setInfectiousnessWeight(Infectiousness.HIGH, infectiousnessWeights[2])
setAttenuationBuckets(attenuationBucketThresholdDb, attenuationBucketWeights)
setMinimumWindowScore(AppConfig.minimumWindowScore)
}.build()
).addOnSuccessListener {
if (filter) {
cont.resume(it.filter {
it.summaryData.maximumScore >= AppConfig.minimumWindowScore
})
} else {
cont.resume(it)
}
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun getDailySummariesFromDbByExposureDate(): List<DailySummaryEntity> {
return db.dao().getAllByExposureDate()
}
suspend fun getDailySummariesFromDbByImportDate(): List<DailySummaryEntity> {
return db.dao().getAllByImportDate()
}
suspend fun getLastRiskyExposure(demo: Boolean? = false): DailySummaryEntity? {
val lastExposure = db.dao().getLatest().firstOrNull()
return if (lastExposure == null && demo == true) {
getLastRiskyExposureForDemo()
} else {
lastExposure
}
}
private fun getLastRiskyExposureForDemo(): DailySummaryEntity {
return DailySummaryEntity(
LocalDate.now().minusDays(1).toEpochDay().toInt(),
1000.0,
1000.0,
1000.0,
0,
notified = false,
accepted = false
)
}
suspend fun markAsAccepted() {
db.dao().markAsAccepted()
}
suspend fun getTemporaryExposureKeyHistory(): List<TemporaryExposureKey> =
suspendCoroutine { cont ->
client.temporaryExposureKeyHistory.addOnSuccessListener {
cont.resume(it)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun getExposureWindows(): List<ExposureWindow> = suspendCoroutine { cont ->
client.exposureWindows
.addOnSuccessListener {
cont.resume(it)
}.addOnFailureListener {
cont.resumeWithException(it)
}
}
suspend fun verifyCode(code: String) {
try {
val verifyResponse = server.verifyCode(VerifyCodeRequest(code))
if (verifyResponse.token != null) {
L.i("Verify code success")
prefs.setVerificationData(code, verifyResponse.token)
} else {
throw VerifyException(verifyResponse.error, verifyResponse.errorCode)
}
} catch (e: HttpException) {
var errorResponse: VerifyCodeResponse? = null
try {
val errorBody = e.response()?.errorBody()?.string()
errorResponse =
Gson().fromJson<VerifyCodeResponse>(errorBody, VerifyCodeResponse::class.java)
} catch (e: Throwable) {
L.e(e)
}
// called when we have HTTP not 200
if (e.code() == 500 && AppConfig.handleError500AsInvalidCode) {
// This should be enabled only on the old prod server
throw VerifyException("Invalid code", VerifyCodeResponse.ERROR_CODE_INVALID_CODE)
} else if (e.code() == 400) {
if (errorResponse?.errorCode == VerifyCodeResponse.ERROR_CODE_INVALID_CODE || errorResponse?.errorCode == VerifyCodeResponse.ERROR_CODE_EXPIRED_CODE) {
throw VerifyException(errorResponse.error, errorResponse.errorCode)
} else if (AppConfig.handleError400AsExpiredOrUsedCode) {
throw VerifyException(
errorResponse?.error,
VerifyCodeResponse.ERROR_CODE_EXPIRED_USED_CODE
)
} else {
throw VerifyException(errorResponse?.error, errorResponse?.errorCode)
}
} else {
throw VerifyException(errorResponse?.error, errorResponse?.errorCode)
}
}
}
suspend fun publishKeys(): Int {
val keys = getTemporaryExposureKeyHistory()
if (keys.isEmpty()) {
L.e("No keys found, upload cancelled")
throw NoKeysException()
}
if (prefs.hasValidationToken(useLeeway = false)) {
val token = prefs.getVerificationToken()!!
val hmackey = cryptoTools.newHmacKey()
val keyHash = cryptoTools.hashedKeys(keys, hmackey)
val certificateResponse = server.verifyCertificate(
VerifyCertificateRequest(token, keyHash)
)
if (certificateResponse.error != null) {
// We ignore error in certificate verification, only log it. It was causing error in production builds with older server.
L.e("Error in certificate verification: " + certificateResponse.error + " (" + certificateResponse.errorCode + ")")
} else {
L.i("Verify certificate success")
}
val dtos = keys.map {
TemporaryExposureKeyDto(
it.keyData.encodeBase64(),
it.rollingStartIntervalNumber,
it.rollingPeriod
)
}
L.i("Uploading ${dtos.size} keys")
val response = server.reportExposure(dtos, certificateResponse.certificate, hmackey)
response.errorMessage?.let {
L.e("Report exposure failed: $it")
throw ReportExposureException(it, response.code)
}
L.i("Report exposure success, ${response.insertedExposures} keys inserted")
return response.insertedExposures ?: 0
} else {
throw InvalidTokenException()
}
}
suspend fun checkExposure() {
db.dao().deleteOld()
val timestamp = System.currentTimeMillis()
db.dao().insert(getDailySummariesFromApi().map {
DailySummaryEntity(
daysSinceEpoch = it.daysSinceEpoch,
maximumScore = it.summaryData.maximumScore,
scoreSum = it.summaryData.scoreSum,
weightenedDurationSum = it.summaryData.weightedDurationSum,
importTimestamp = timestamp,
notified = false,
accepted = false
)
})
// latest exposure found in the database
val latestExposure = db.dao().getLatest().firstOrNull()
val latestExposureTime = latestExposure?.daysSinceEpoch
// latest exposure that the user was not notified about yet
val lastNotifiedExposureTime = db.dao().getLastNotified().firstOrNull()?.daysSinceEpoch
// the app should show a notification if there is a new exposure the user was not notified
// about, yet, or if there is an exposure, but the app has not been opened since the last
// last notification
val userNotNotifiedAboutLatest = latestExposureTime != null
&& latestExposureTime != lastNotifiedExposureTime
val lastAppUsedTimestamp = prefs.getLastTimeAppVisited()
// Suppress showing update screen after launching the app for first time with a new exposure.
prefs.setSuppressUpdateScreens(true)
// We can use the import timestamp of the exposure as we are interested in comparing whether
// the user visited the app after being notified. The notification can take place only when
// the exposure is imported.
// In case there is no exposure, the timestamp will default to 0.
// It won't cause a false positive as the app timestamp will always be greater than 0.
val lastExposureTimestamp = latestExposure?.importTimestamp ?: 0L
// If the app visit timestamp is not saved yet, it acts as if the user has not opened the app.
// To reduce false positives, we should check the timestamp is non-zero.
val appNotOpenedSinceLastNotification = (lastAppUsedTimestamp > 0)
&& (lastExposureTimestamp > lastAppUsedTimestamp)
val shouldNotify = userNotNotifiedAboutLatest || appNotOpenedSinceLastNotification
if (shouldNotify) {
notifications.showRiskyExposureNotification()
db.dao().markAsNotified()
firebaseFunctionsRepository.registerNotification()
} else {
L.i(
"Not showing notification, lastExposure=$latestExposureTime, " +
"lastNotifiedExposure=$lastNotifiedExposureTime"
)
}
}
fun scheduleSelfChecker() {
//TODO: Uncomment if eRouška gets resurrected
/*val constraints = Constraints.Builder().build()
val worker = PeriodicWorkRequestBuilder<SelfCheckerWorker>(
AppConfig.selfCheckerPeriodHours,
TimeUnit.HOURS
).setConstraints(constraints)
.addTag(SelfCheckerWorker.TAG)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
SelfCheckerWorker.TAG,
ExistingPeriodicWorkPolicy.REPLACE,
worker
)*/
}
suspend fun isEligibleToDownloadKeys(): Boolean {
return isEnabled() && System.currentTimeMillis() - prefs.getLastKeyImport() >= AppConfig.keyImportPeriodHours * 60 * 60 * 1000
}
fun isLocationlessScanSupported() = client.deviceSupportsLocationlessScanning()
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/Notifications.kt
================================================
package cz.covid19cz.erouska.exposurenotifications
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.graphics.Color
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_MAX
import androidx.work.WorkManager
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.ktx.Firebase
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.ktx.messaging
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.db.SharedPrefsRepository
import cz.covid19cz.erouska.ext.isNetworkAvailable
import cz.covid19cz.erouska.net.FirebaseFunctionsRepository
import cz.covid19cz.erouska.ui.main.MainActivity
import cz.covid19cz.erouska.utils.L
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class Notifications @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SharedPrefsRepository,
private val firebaseFunctionsRepository: FirebaseFunctionsRepository
) {
companion object {
const val CHANNEL_ID_EXPOSURE = "EXPOSURE"
const val CHANNEL_ID_OUTDATED_DATA = "OUTDATED_DATA"
const val CHANNEL_ID_NOT_RUNNING = "NOT_RUNNING"
const val CHANNEL_ID_DOWNLOADING = "DOWNLOADING"
const val REQ_ID_EXPOSURE = 100
const val REQ_ID_OUTDATED_DATA = 101
const val REQ_ID_NOT_RUNNING = 102
const val REQ_ID_DOWNLOADING = 103
}
fun showErouskaPausedNotification() {
showNotification(
R.string.dashboard_title_paused,
R.string.notification_exposure_notifications_off_text,
CHANNEL_ID_NOT_RUNNING
)
}
fun showRiskyExposureNotification() {
showNotification(
R.string.notification_exposure_title,
R.string.notification_exposure_text,
CHANNEL_ID_EXPOSURE,
autoCancel = true,
color = Color.RED,
priority = PRIORITY_MAX
)
}
fun showOutdatedDataNotification() {
showNotification(
context.getString(R.string.notification_data_outdated_title),
AppConfig.recentExposureNotificationTitle,
CHANNEL_ID_OUTDATED_DATA
)
}
private fun showNotification(
@StringRes title: Int,
@StringRes text: Int,
channelId: String,
autoCancel: Boolean = false,
color: Int? = null,
priority: Int? = null
) {
showNotification(
context.getString(title),
context.getString(text),
channelId,
autoCancel,
color,
priority
)
}
private fun showNotification(
title: String,
text: String,
channelId: String,
autoCancel: Boolean = false,
color: Int? = null,
priority: Int? = null
) {
val builder = NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(R.drawable.ic_notification_normal)
.setContentIntent(getContentIntent())
.setAutoCancel(autoCancel)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(text)
.setBigContentTitle(title)
)
color?.let {
builder.setColorized(true)
builder.color = color
}
priority?.let {
builder.priority = priority
}
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(
when (channelId) {
CHANNEL_ID_EXPOSURE -> REQ_ID_EXPOSURE
CHANNEL_ID_OUTDATED_DATA -> REQ_ID_OUTDATED_DATA
CHANNEL_ID_NOT_RUNNING -> REQ_ID_NOT_RUNNING
else -> 0
}, builder.build()
)
}
fun dismissNotRunningNotification() {
dismissNotification(REQ_ID_NOT_RUNNING)
}
fun dismissOudatedDataNotification() {
dismissNotification(REQ_ID_OUTDATED_DATA)
}
fun getDownloadingNotification(workId: UUID): Notification {
val cancelIntent = WorkManager.getInstance(context)
.createCancelPendingIntent(workId)
return NotificationCompat.Builder(context, CHANNEL_ID_DOWNLOADING)
.setContentTitle(context.getString(R.string.notification_downloading_title))
.setContentText(context.getString(R.string.notification_downloading_description))
.setContentIntent(getContentIntent())
.setSmallIcon(R.drawable.ic_notification_normal)
.addAction(
android.R.drawable.ic_delete,
context.getString(android.R.string.cancel),
cancelIntent
)
.setOngoing(true)
.build()
}
suspend fun getCurrentPushToken(): String {
val pushToken = FirebaseMessaging.getInstance().token.await()
L.d("Push token=$pushToken")
return pushToken
}
private fun dismissNotification(id: Int) {
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(
id
)
}
private fun getContentIntent(): PendingIntent {
//TODO: Uncomment if eRouška gets resurrected
//val notificationIntent = Intent(context, MainActivity::class.java)
//TODO: Remove if eRouška gets resurrected
val notificationIntent = Intent(context, MainActivity::class.java)
notificationIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
return PendingIntent.getActivity(
context,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
fun init() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(
CHANNEL_ID_EXPOSURE,
context.getString(R.string.notification_channel_exposure),
NotificationManager.IMPORTANCE_MAX,
context
)
createNotificationChannel(
CHANNEL_ID_NOT_RUNNING,
context.getString(R.string.notification_channel_exposure_notifications_off),
NotificationManager.IMPORTANCE_DEFAULT,
context
)
createNotificationChannel(
CHANNEL_ID_OUTDATED_DATA,
context.getString(R.string.notification_channel_outdated_data),
NotificationManager.IMPORTANCE_DEFAULT,
context
)
createNotificationChannel(
CHANNEL_ID_DOWNLOADING,
context.getString(R.string.notification_channel_downloading),
NotificationManager.IMPORTANCE_MIN,
context
)
}
if (!prefs.isPushTokenRegistered() && context.isNetworkAvailable() && FirebaseAuth.getInstance().currentUser != null) {
GlobalScope.launch {
try {
firebaseFunctionsRepository.changePushToken(getCurrentPushToken())
} catch (e: Throwable) {
L.e(e)
}
}
}
if (!prefs.isPushTopicRegistered() && context.isNetworkAvailable()) {
GlobalScope.launch(Dispatchers.IO) {
try {
Firebase.messaging.subscribeToTopic("budicek").await()
prefs.setPushTopicRegistered()
L.d("Topic 'budicek' registered")
} catch (e: Throwable) {
L.e(e)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(
id: String,
name: String,
importance: Int,
context: Context
): NotificationChannel {
val channel = NotificationChannel(id, name, importance)
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
channel
)
return channel
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/receiver/ExposureNotificationBroadcastReceiver.kt
================================================
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package cz.covid19cz.erouska.exposurenotifications.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsRepository
import cz.covid19cz.erouska.utils.L
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Broadcast receiver for callbacks from exposure notification API.
*/
@AndroidEntryPoint
class ExposureNotificationBroadcastReceiver : BroadcastReceiver() {
@Inject
internal lateinit var exposureNotificationsRepository : ExposureNotificationsRepository
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED) {
GlobalScope.launch {
try {
exposureNotificationsRepository.checkExposure()
} catch (e: Throwable) {
L.e(e)
}
}
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/service/PushService.kt
================================================
package cz.covid19cz.erouska.exposurenotifications.service
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import cz.covid19cz.erouska.net.ExposureServerRepository
import cz.covid19cz.erouska.net.FirebaseFunctionsRepository
import cz.covid19cz.erouska.utils.L
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class PushService : FirebaseMessagingService() {
@Inject
lateinit var firebaseFunctionsRepository: FirebaseFunctionsRepository
@Inject
lateinit var exposureNotificationsServerRepository: ExposureServerRepository
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
L.d("Push message received: ${message.data}")
if (message.data.containsKey("downloadKeyExport")) {
exposureNotificationsServerRepository.scheduleKeyDownload()
}
}
override fun onNewToken(newToken: String) {
super.onNewToken(newToken)
L.d("New push token: $newToken")
if (FirebaseAuth.getInstance().currentUser != null) {
GlobalScope.launch {
try {
firebaseFunctionsRepository.changePushToken(newToken)
} catch (e: Throwable) {
L.e(e)
}
}
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/worker/DownloadKeysWorker.kt
================================================
package cz.covid19cz.erouska.exposurenotifications.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsRepository
import cz.covid19cz.erouska.exposurenotifications.Notifications
import cz.covid19cz.erouska.net.ExposureServerRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class DownloadKeysWorker @AssistedInject constructor(
@Assisted val context: Context,
@Assisted workerParams: WorkerParameters,
private val exposureNotificationsRepository: ExposureNotificationsRepository,
private val serverRepository: ExposureServerRepository,
private val notifications: Notifications
) : CoroutineWorker(context, workerParams) {
companion object {
const val TAG = "DOWNLOAD_KEYS"
}
override suspend fun doWork(): Result {
//TODO: Remove if eRouška gets resurrected
/**
try {
setForeground(ForegroundInfo(Notifications.REQ_ID_DOWNLOADING, notifications.getDownloadingNotification(id)))
if (exposureNotificationsRepository.isEligibleToDownloadKeys()) {
L.i("Starting download keys worker")
Analytics.logEvent(context, Analytics.KEY_EXPORT_DOWNLOAD_STARTED)
val result = serverRepository.downloadKeyExport()
exposureNotificationsRepository.provideDiagnosisKeys(result)
exposureNotificationsRepository.checkExposure()
Analytics.logEvent(context, Analytics.KEY_EXPORT_DOWNLOAD_FINISHED)
} else {
L.i("Skipping download keys worker")
}
return Result.success()
} catch (t: Throwable) {
L.e(t)
return if (runAttemptCount < 3) {
Result.retry()
} else {
Result.failure()
}
}**/
return Result.success()
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/worker/SelfCheckerWorker.kt
================================================
package cz.covid19cz.erouska.exposurenotifications.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import cz.covid19cz.erouska.db.SharedPrefsRepository
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsRepository
import cz.covid19cz.erouska.exposurenotifications.Notifications
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class SelfCheckerWorker @AssistedInject constructor(
@Assisted val context: Context,
@Assisted workerParams: WorkerParameters,
private val prefs: SharedPrefsRepository,
private val exposureNotificationsRepository: ExposureNotificationsRepository,
private val notifications: Notifications
) : CoroutineWorker(context, workerParams) {
companion object {
const val TAG = "SELF_CHECKER"
}
override suspend fun doWork(): Result {
//TODO: Remove if eRouška gets resurrected
/**
val hour = Calendar.getInstance(Locale.getDefault()).get(Calendar.HOUR_OF_DAY)
if (hour in 9..19) {
if (!exposureNotificationsRepository.isEnabled()) {
notifications.showErouskaPausedNotification()
}
if (prefs.hasOutdatedKeyData()) {
notifications.showOutdatedDataNotification()
}
}**/
return Result.success()
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/ByteArray.kt
================================================
package cz.covid19cz.erouska.ext
val ByteArray.asHexLower inline get() = this.joinToString(separator = ""){ String.format("%02x",(it.toInt() and 0xFF))}
val String.hexAsByteArray inline get() = this.chunked(2).map { it.toUpperCase().toInt(16).toByte() }.toByteArray()
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/Context.kt
================================================
package cz.covid19cz.erouska.ext
import android.app.Activity
import android.bluetooth.BluetoothManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.annotation.StringRes
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.ui.base.BaseFragment
import cz.covid19cz.erouska.utils.CustomTabHelper
import cz.covid19cz.erouska.utils.L
fun Context.isBtEnabled(): Boolean {
val btManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
return btManager.adapter.isEnabled
}
fun Context?.isLocationEnabled(): Boolean {
val locationManager = this?.getSystemService(LocationManager::class.java) ?: return false
return LocationManagerCompat.isLocationEnabled(locationManager)
}
fun Context.openPermissionsScreen() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val uri: Uri = Uri.fromParts("package", packageName, null)
intent.data = uri
startActivity(intent)
}
@Suppress("DEPRECATION")
fun Context.isNetworkAvailable(): Boolean {
val connectivityManager =
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
with(connectivityManager) {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
activeNetworkInfo?.isConnected
} else {
getNetworkCapabilities(activeNetwork)?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} ?: false
}
}
fun Context.shareApp() {
val text = getString(R.string.share_app_text, AppConfig.shareAppDynamicLink)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, text)
startActivity(Intent.createChooser(intent, getString(R.string.share_app_title)))
}
fun BaseFragment<*, *>.showWeb(url: String, customTabHelper: CustomTabHelper) {
val intent = CustomTabsIntent.Builder()
.setShowTitle(true)
.setToolbarColor(ContextCompat.getColor(requireContext(), R.color.colorPrimary))
.setCloseButtonIcon(
BitmapFactory.decodeResource(
resources,
R.drawable.ic_action_up
)
)
.build()
if (customTabHelper.chromePackageName != null) {
intent.launchUrl(requireContext(), Uri.parse(url))
} else {
// Custom Tabs not available
try {
startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(url)
)
)
} catch (e: ActivityNotFoundException) {
L.e(e)
}
}
}
fun Activity.sendEmail(
recipient: String,
subject: Int,
file: Uri? = null,
@StringRes emailBody: Int
) {
val originalIntent = createEmailShareIntent(recipient, subject, file, emailBody)
val emailFilterIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
val originalIntentResults = packageManager.queryIntentActivities(originalIntent, 0)
val emailFilterIntentResults = packageManager.queryIntentActivities(emailFilterIntent, 0)
val targetedIntents = originalIntentResults
.filter { originalResult -> emailFilterIntentResults.any { originalResult.activityInfo.packageName == it.activityInfo.packageName } }
.map {
createEmailShareIntent(recipient, subject, file, emailBody).apply {
`package` = it.activityInfo.packageName
}
}
.toMutableList()
if (targetedIntents.size > 0) {
val finalIntent = Intent.createChooser(
targetedIntents.removeAt(0),
getString(R.string.support_email_chooser)
)
finalIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, targetedIntents.toTypedArray())
startActivity(finalIntent)
}
}
private fun Activity.createEmailShareIntent(
recipient: String,
subject: Int,
file: Uri?,
@StringRes emailBody: Int
): Intent {
val builder = ShareCompat.IntentBuilder.from(this)
.setType("message/rfc822")
.setEmailTo(arrayOf(recipient))
.setSubject(getString(subject))
.setText(getString(emailBody))
if (file != null) {
builder.setStream(file)
}
return builder.intent
}
fun Context.getInstallDay(): String {
val packageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS)
val installTimestamp = packageInfo.firstInstallTime
return if (installTimestamp > 0) {
installTimestamp.timestampToDate()
} else {
"N/A"
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/Int.kt
================================================
package cz.covid19cz.erouska.ext
import java.text.SimpleDateFormat
import java.util.*
fun Int.daysSinceEpochToDateString(pattern: String = "d. M. yyyy"): String {
val formatter = SimpleDateFormat(pattern, Locale.getDefault())
formatter.timeZone = TimeZone.getTimeZone("UTC")
val dateTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
timeInMillis = (toLong() * 24 * 60 * 60 * 1000)
}
return formatter.format(dateTime.time)
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/Long.kt
================================================
package cz.covid19cz.erouska.ext
import java.text.SimpleDateFormat
import java.util.*
fun Long.timestampToDate(): String {
return SimpleDateFormat("d. M. yyyy", Locale.getDefault()).format(Date(this))
}
fun Long.timestampToTime(): String {
return SimpleDateFormat("H:mm", Locale.getDefault()).format(Date(this))
}
fun Long.timestampToDateTime(): String {
return SimpleDateFormat("d.M.yyyy H:mm", Locale.getDefault()).format(Date(this))
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/Rx.kt
================================================
package cz.covid19cz.erouska.ext
import io.reactivex.Flowable
import io.reactivex.Maybe
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
fun <T> Observable<T>.execute(onNext : (t :T) -> Unit, onError : (t :Throwable) -> Unit): Disposable {
return inBackground().subscribe(onNext, onError)
}
fun <T> Single<T>.execute(onNext : (t :T) -> Unit, onError : (t :Throwable) -> Unit): Disposable {
return inBackground().subscribe(onNext, onError)
}
fun <T> Maybe<T>.execute(onSuccess : (t :T) -> Unit, onError : (t :Throwable) -> Unit): Disposable {
return inBackground().subscribe(onSuccess, onError)
}
fun <T> Flowable<T>.execute(onNext : (t :T) -> Unit, onError : (t :Throwable) -> Unit): Disposable {
return inBackground().subscribe(onNext, onError)
}
fun <T> Observable<T>.inBackground(): Observable<T> {
return subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
fun <T> Single<T>.inBackground(): Single<T> {
return subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
fun <T> Maybe<T>.inBackground(): Maybe<T> {
return subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
fun <T> Flowable<T>.inBackground(): Flowable<T> {
return subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/String.kt
================================================
package cz.covid19cz.erouska.ext
fun String.toIntList() : List<Int>{
return this.split(",").map { it.toInt() }
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ext/View.kt
================================================
package cz.covid19cz.erouska.ext
import android.content.Context
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
fun EditText.setOnDoneListener(onDone: () -> Unit) {
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
onDone()
return@setOnEditorActionListener true
}
return@setOnEditorActionListener false
}
}
fun View.attachKeyboardController() {
setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
showKeyboard()
} else {
hideKeyboard()
}
}
}
fun View.showKeyboard() {
post {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}
fun View.hideKeyboard() {
post {
val im = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
im.hideSoftInputFromWindow(this.windowToken, 0)
}
}
fun View.hide() {
visibility = View.GONE
}
fun View.show() {
visibility = View.VISIBLE
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/ExposureServerRepository.kt
================================================
package cz.covid19cz.erouska.net
import android.content.Context
import androidx.work.*
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.BuildConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.db.SharedPrefsRepository
import cz.covid19cz.erouska.exposurenotifications.worker.DownloadKeysWorker
import cz.covid19cz.erouska.net.api.KeyServerApi
import cz.covid19cz.erouska.net.api.VerificationServerApi
import cz.covid19cz.erouska.net.model.*
import cz.covid19cz.erouska.utils.L
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.*
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.DataInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.lang.reflect.Type
import java.net.URL
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ExposureServerRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SharedPrefsRepository
) {
private val okhttpBuilder by lazy {
val builder = OkHttpClient.Builder()
builder.addInterceptor(UserAgentInterceptor())
if (BuildConfig.DEBUG) {
builder.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
builder
}
private val keyServerClient by lazy {
Retrofit.Builder()
.baseUrl(context.getString(R.string.key_server_base_url))
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
.client(okhttpBuilder.build())
.build().create(KeyServerApi::class.java)
}
private val verificationServerClient by lazy {
Retrofit.Builder()
.baseUrl(context.getString(R.string.verification_server_base_url))
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
.client(okhttpBuilder.addInterceptor {
val request = it.request().newBuilder()
.addHeader("X-API-Key", AppConfig.verificationServerApiKey)
.build()
it.proceed(request)
}.build())
.build().create(VerificationServerApi::class.java)
}
suspend fun reportExposure(
temporaryExposureKeyDto: List<TemporaryExposureKeyDto>,
certificate: String,
hmackey: String
): ExposureResponse {
return withContext(Dispatchers.IO) {
keyServerClient.reportExposure(
ExposureRequest(
temporaryExposureKeys = temporaryExposureKeyDto,
verificationPayload = certificate,
hmackey = hmackey,
revisionToken = null,
traveler = prefs.isTraveller(),
consentToFederation = prefs.isConsentToFederation(),
reportType = AppConfig.efgsReportType,
visitedCountries = if (prefs.isTraveller()) AppConfig.efgsVisitedCountries else emptyList(),
symptomOnsetInterval = prefs.getSymptomOnsetInterval()
)
)
}
}
suspend fun verifyCode(request: VerifyCodeRequest): VerifyCodeResponse {
return withContext(Dispatchers.IO) {
verificationServerClient.verifyCode(request)
}
}
suspend fun verifyCertificate(request: VerifyCertificateRequest): VerifyCertificateResponse {
return withContext(Dispatchers.IO) {
verificationServerClient.verifyCertificate(request)
}
}
suspend fun cover(request: CoverRequest): CoverResponse {
return withContext(Dispatchers.IO) {
verificationServerClient.cover(request)
}
}
suspend fun downloadKeyExport(): List<DownloadedKeys> {
return withContext(Dispatchers.IO) {
val countryUrls = parseCountryUrls(
if (prefs.isTraveller()) {
AppConfig.keyExportEuTravellerUrls
} else {
AppConfig.keyExportNonTravellerUrls
}
)
val keysList = mutableListOf<DownloadedKeys>()
val keysListTasks = mutableListOf<Deferred<DownloadedKeys?>>()
countryUrls.forEach {
keysListTasks.add(async { downloadIndex(it) })
}
keysList.addAll(keysListTasks.awaitAll().filterNotNull())
return@withContext keysList
}
}
private fun parseCountryUrls(json: String): List<String> {
val countryUrlListType: Type = object : TypeToken<ArrayList<CountryUrl>?>() {}.type
val countryUrls: ArrayList<CountryUrl> = Gson().fromJson(json, countryUrlListType)
return countryUrls.map { it.url }
}
private suspend fun downloadIndex(url: String): DownloadedKeys? {
return withContext(Dispatchers.IO) {
val indexContent = readURLContent(url)
if (indexContent != null) {
val lastDownloadedFile = prefs.lastKeyExportFileName(url)
var fileNames = indexContent.split('\n')
// Find index of last downloaded file and get everything after it
val indexOfLastDownload = fileNames.indexOf(lastDownloadedFile)
if (indexOfLastDownload != -1) {
fileNames = fileNames.subList(indexOfLastDownload + 1, fileNames.size)
}
val extractedFiles = mutableListOf<File>()
val zipUrls = fileNames.map { AppConfig.keyExportUrl + it }
val downloads = mutableListOf<Deferred<File?>>()
zipUrls.forEach {
downloads.add(async { downloadFile(it) })
}
extractedFiles.addAll(downloads.awaitAll().filterNotNull())
return@withContext DownloadedKeys(url, extractedFiles, fileNames)
} else {
return@withContext null
}
}
}
private fun downloadFile(zipfile: String): File? {
try {
val dir = File(context.cacheDir.path + "/export/")
val fileName = if (zipfile.contains("efgs")) {
// EFGS files; e.g. efgs_de/1607061600-1607068800-00001.zip
zipfile.split("/").takeLast(2).joinToString("/")
} else {
// CZ files; e.g. 1607061600-1607068800-00001.zip
zipfile.substring(zipfile.lastIndexOf("/") + 1)
}
val file = File(dir.absolutePath + "/" + fileName)
checkNotNull(file.parentFile).mkdirs()
file.createNewFile()
val u = URL(zipfile)
val inputStream: InputStream = u.openStream()
val dis = DataInputStream(inputStream)
val buffer = ByteArray(1024)
var length: Int
val fos = FileOutputStream(file)
while (dis.read(buffer).also { length = it } > 0) {
fos.write(buffer, 0, length)
}
return file
} catch (t: Throwable) {
L.e(t)
}
return null
}
private fun readURLContent(url: String): String? {
return try {
val indexConnection = URL(url).openConnection()
val indexInputStream = indexConnection.getInputStream()
indexInputStream.readBytes().toString(Charsets.UTF_8)
} catch (e: Throwable) {
L.w("Skipping index download due to $e")
null
}
}
fun scheduleKeyDownload() {
//TODO: Uncomment if eRouška gets resurrected
/*val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val worker = PeriodicWorkRequestBuilder<DownloadKeysWorker>(
AppConfig.keyImportPeriodHours,
TimeUnit.HOURS
).setConstraints(constraints)
.addTag(DownloadKeysWorker.TAG)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
DownloadKeysWorker.TAG,
ExistingPeriodicWorkPolicy.REPLACE,
worker
)*/
}
fun deleteFiles() {
val extractedDir = File(context.cacheDir.path + "/export/")
extractedDir.deleteRecursively()
prefs.clearLastKeyExportFileName()
prefs.clearLastKeyImportTime()
}
class UserAgentInterceptor : Interceptor {
companion object {
const val USER_AGENT = "User-agent"
}
private val userAgent =
"eRouska-Android-${BuildConfig.BUILD_TYPE}/${BuildConfig.VERSION_NAME}"
override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder()
requestBuilder.addHeader(USER_AGENT, userAgent)
return chain.proceed(requestBuilder.build())
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/FirebaseFunctionsRepository.kt
================================================
package cz.covid19cz.erouska.net
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.functions.ktx.functions
import com.google.firebase.ktx.Firebase
import com.google.gson.Gson
import cz.covid19cz.erouska.AppConfig.FIREBASE_REGION
import cz.covid19cz.erouska.db.SharedPrefsRepository
import cz.covid19cz.erouska.net.exception.UnauthrorizedException
import cz.covid19cz.erouska.net.model.CovidStatsResponse
import cz.covid19cz.erouska.net.model.DownloadMetricsResponse
import cz.covid19cz.erouska.utils.DeviceInfo
import cz.covid19cz.erouska.utils.L
import cz.covid19cz.erouska.utils.LocaleUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.suspendCoroutine
@Singleton
class FirebaseFunctionsRepository @Inject constructor(
private val deviceInfo: DeviceInfo,
private val prefs: SharedPrefsRepository
) {
/**
* Creates a new registration, saved to registrations collection.
*/
suspend fun register(pushRegistrationToken: String) {
withContext(Dispatchers.IO) {
val data = hashMapOf(
"platform" to "android",
"platformVersion" to deviceInfo.getAndroidVersion(),
"manufacturer" to deviceInfo.getManufacturer(),
"model" to deviceInfo.getDeviceName(),
"locale" to LocaleUtils.getLocale(),
"pushRegistrationToken" to pushRegistrationToken
)
val token = checkNotNull(callFunction("RegisterEhrid", data)["customToken"])
FirebaseAuth.getInstance().signInWithCustomToken(token).await()
if (FirebaseAuth.getInstance().currentUser == null) {
throw RuntimeException("Sign in failed")
}
prefs.setPushTokenRegistered()
}
}
/**
* Returns data from collections covidDataIncrease and covidDataTotal for the given input date (if date is missing, TODAY is used)
*/
suspend fun getStats(date: String? = null): CovidStatsResponse {
return withContext(Dispatchers.IO) {
val data = hashMapOf(
"idToken" to getIdToken(),
"date" to date
)
val covidStats = callFunction("GetCovidData", data)
Gson().fromJson(covidStats.toString(), CovidStatsResponse::class.java)
}
}
/**
* Returns data from collections DownloadMetrics
*/
suspend fun getDownloadMetrics(): DownloadMetricsResponse {
val covidStats = callFunction("DownloadMetrics")
return Gson().fromJson(covidStats.toString(), DownloadMetricsResponse::class.java)
}
/**
* In the registrations collection, changes the value of lastNotificationStatus attribute to sent and the value of lastNotificationUpdatedAt attribute to CURRENT_TIMESTAMP for a given idToken.
*/
suspend fun registerNotification() {
withContext(Dispatchers.IO) {
val data = hashMapOf(
"idToken" to getIdToken()
)
callFunction("RegisterNotification", data)
}
}
/**
* Changes push token
*/
suspend fun changePushToken(pushRegistrationToken: String) {
withContext(Dispatchers.IO) {
val data = hashMapOf(
"idToken" to getIdToken(),
"pushRegistrationToken" to pushRegistrationToken
)
callFunction("ChangePushToken", data)
prefs.setPushTokenRegistered()
}
}
private suspend fun getIdToken(): String = suspendCoroutine { cont ->
if (FirebaseAuth.getInstance().currentUser != null) {
FirebaseAuth.getInstance().currentUser?.getIdToken(false)?.addOnSuccessListener {
cont.resumeWith(Result.success(checkNotNull(it.token)))
}?.addOnFailureListener {
cont.resumeWith(Result.failure(it))
}
} else {
cont.resumeWith(Result.failure(UnauthrorizedException()))
}
}
/**
* Generic function for calling Firebase Function.
*/
private suspend fun callFunction(
name: String,
data: Map<String, String?>? = null
): Map<String, String> {
L.d("Calling function $name with data: $data")
@Suppress("UNCHECKED_CAST")
val response = Firebase.functions(FIREBASE_REGION).getHttpsCallable(name).call(data)
.continueWith { task ->
(task.result?.data as HashMap<String, String>)
}.await()
L.d("Function $name response: $response")
return response
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/api/KeyServerApi.kt
================================================
package cz.covid19cz.erouska.net.api
import cz.covid19cz.erouska.net.model.ExposureRequest
import cz.covid19cz.erouska.net.model.ExposureResponse
import retrofit2.http.Body
import retrofit2.http.POST
interface KeyServerApi {
@POST("PublishKeys")
suspend fun reportExposure(@Body exposureRequest: ExposureRequest) : ExposureResponse
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/api/VerificationServerApi.kt
================================================
package cz.covid19cz.erouska.net.api
import cz.covid19cz.erouska.net.model.*
import retrofit2.http.Body
import retrofit2.http.POST
interface VerificationServerApi {
@POST("api/verify")
suspend fun verifyCode(@Body request: VerifyCodeRequest): VerifyCodeResponse
@POST("api/certificate")
suspend fun verifyCertificate(@Body request: VerifyCertificateRequest): VerifyCertificateResponse
@POST("api/cover")
suspend fun cover(@Body request: CoverRequest): CoverResponse
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/exception/UnauthrorizedException.kt
================================================
package cz.covid19cz.erouska.net.exception
class UnauthrorizedException : Throwable()
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/model/CovidDataModel.kt
================================================
package cz.covid19cz.erouska.net.model
import com.google.gson.annotations.SerializedName
data class CovidStatsResponse(
@SerializedName("date") val date: String?,
@SerializedName("pcrTestsTotal") val testsTotal: Int?,
@SerializedName("pcrTestsIncrease") val testsIncrease: Int?,
@SerializedName("pcrTestsIncreaseDate") val testsIncreaseDate: String?,
@SerializedName("antigenTestsTotal") val antigenTestsTotal: Int?,
@SerializedName("antigenTestsIncrease") val antigenTestsIncrease: Int?,
@SerializedName("antigenTestsIncreaseDate") val antigenTestsIncreaseDate: String?,
@SerializedName("vaccinationsTotal") val vaccinationsTotal: Int?,
@SerializedName("vaccinationsIncrease") val vaccinationsIncrease: Int?,
@SerializedName("vaccinationsIncreaseDate") val vaccinationsIncreaseDate: String?,
@SerializedName("confirmedCasesTotal") val confirmedCasesTotal: Int?,
@SerializedName("confirmedCasesIncrease") val confirmedCasesIncrease: Int?,
@SerializedName("confirmedCasesIncreaseDate") val confirmedCasesIncreaseDate: String?,
@SerializedName("activeCasesTotal") val activeCasesTotal: Int?,
@SerializedName("activeCasesIncrease") val activeCasesIncrease: Int?,
@SerializedName("curedTotal") val curedTotal: Int?,
@SerializedName("curedIncrease") val curedIncrease: Int?,
@SerializedName("deceasedTotal") val deceasedTotal: Int?,
@SerializedName("deceasedIncrease") val deceasedIncrease: Int?,
@SerializedName("currentlyHospitalizedTotal") val currentlyHospitalizedTotal: Int?,
@SerializedName("currentlyHospitalizedIncrease") val currentlyHospitalizedIncrease: Int?,
@SerializedName("vaccinationsTotalFirstDose") val vaccinationsTotalFirstDose: Int?,
@SerializedName("vaccinationsDailyFirstDose") val vaccinationsDailyFirstDose: Int?,
@SerializedName("vaccinationsTotalSecondDose") val vaccinationsTotalSecondDose: Int?,
@SerializedName("vaccinationsDailySecondDose") val vaccinationsDailySecondDose: Int?,
@SerializedName("vaccinationsDailyDosesDate") val vaccinationsDailyDosesDate: String?,
)
data class DownloadMetricsResponse(
@SerializedName("modified") val modified: String?,
@SerializedName("date") val date: String?,
@SerializedName("activations_yesterday") val activationsYesterday: Int?,
@SerializedName("activations_total") val activationsTotal: Int?,
@SerializedName("key_publishers_yesterday") val keyPublishersYesterday: Int?,
@SerializedName("key_publishers_total") val keyPublishersTotal: Int?,
@SerializedName("notifications_yesterday") val notificationsYesterday: Int?,
@SerializedName("notifications_total") val notificationsTotal: Int?
)
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/model/DownloadedKeys.kt
================================================
package cz.covid19cz.erouska.net.model
import cz.covid19cz.erouska.utils.L
import java.io.File
data class DownloadedKeys(
val indexUrl: String,
val files : List<File>,
val urls : List<String>
) {
fun getLastUrl() : String{
return urls.last()
}
fun isValid(): Boolean {
if (files.size != urls.size){
L.w("Inconsistent download (${files.size}/${urls.size})")
}
return files.size == urls.size
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/model/KeyServerModel.kt
================================================
package cz.covid19cz.erouska.net.model
import android.util.Base64
import java.util.*
data class ExposureRequest(
val temporaryExposureKeys: List<TemporaryExposureKeyDto>,
val verificationPayload: String?,
val hmackey: String?,
val revisionToken: String?,
val traveler: Boolean,
val reportType: String,
val visitedCountries: List<String>,
val consentToFederation: Boolean = true,
val symptomOnsetInterval: Long? = null,
val padding: String = Base64.encodeToString(UUID.randomUUID().toString().toByteArray(), Base64.NO_WRAP),
val healthAuthorityID: String = "cz.covid19cz.erouska",
)
data class TemporaryExposureKeyDto(
val key: String,
val rollingStartNumber: Int,
val rollingPeriod: Int
)
data class ExposureResponse(
val revisionToken: String?,
val insertedExposures: Int?,
val errorMessage: String?,
val code: String?
)
data class CountryUrl(
val country: String,
val url: String
)
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/net/model/VerificationServerModel.kt
================================================
package cz.covid19cz.erouska.net.model
// Taken from https://github.com/google/exposure-notifications-verification-server#api-guide-for-app-developers
data class VerifyCodeRequest(
val code: String
)
data class VerifyCodeResponse(
val testType: String?,
val symptomDate: String?,
val token: String?,
val error: String?,
val errorCode: String?
) {
companion object {
const val ERROR_CODE_INVALID_CODE = "code_invalid"
const val ERROR_CODE_EXPIRED_CODE = "code_expired"
const val ERROR_CODE_EXPIRED_USED_CODE = "code_expired_or_used"
}
}
data class VerifyCertificateRequest(
val token: String,
val ekeyhmac: String
)
data class VerifyCertificateResponse(
val certificate: String,
val error: String?,
val errorCode: String?
)
data class CoverRequest(
val data: String
)
data class CoverResponse(
val data: String,
val error: String?
)
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/about/AboutFragment.kt
================================================
package cz.covid19cz.erouska.ui.about
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.text.HtmlCompat
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.BuildConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.databinding.FragmentAboutBinding
import cz.covid19cz.erouska.ext.showWeb
import cz.covid19cz.erouska.ui.base.BaseFragment
import cz.covid19cz.erouska.ui.base.UrlEvent
import cz.covid19cz.erouska.utils.CustomTabHelper
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.fragment_about.*
import javax.inject.Inject
@AndroidEntryPoint
class AboutFragment :
BaseFragment<FragmentAboutBinding, AboutVM>(R.layout.fragment_about, AboutVM::class) {
private var easterEggShown = false
@Inject
internal lateinit var customTabHelper: CustomTabHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
subscribe(UrlEvent::class) {
showWeb(it.url, customTabHelper)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
enableUpInToolbar(true, IconType.UP)
about_tos_content.text = HtmlCompat.fromHtml(
getString(R.string.about_tos_content, AppConfig.conditionsOfUseUrl),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
about_version.setOnLongClickListener {
if (easterEggShown) {
throw RuntimeException("Crashlytics exception test for version ${BuildConfig.VERSION_NAME}.")
} else {
Toast.makeText(context, "Ještě jeden long-press a asi něco vypustíme", Toast.LENGTH_LONG).show()
about_content.text = about_content.text.toString().replace("eRouška", "eRespirátor")
easterEggShown = true
true
}
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/about/AboutVM.kt
================================================
package cz.covid19cz.erouska.ui.about
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.ui.base.BaseVM
import cz.covid19cz.erouska.ui.base.UrlEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class AboutVM @Inject constructor() : BaseVM() {
fun tosLinkClicked() {
publish(UrlEvent(AppConfig.conditionsOfUseUrl))
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/about/entity/AboutProfileItem.kt
================================================
package cz.covid19cz.erouska.ui.about.entity
import com.google.gson.annotations.SerializedName
class AboutProfileItem(
@SerializedName("name")
val name: String?
)
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationFragment.kt
================================================
package cz.covid19cz.erouska.ui.activation
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.navigation.NavOptions.Builder
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.databinding.FragmentActivationBinding
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsErrorHandling
import cz.covid19cz.erouska.ext.hide
import cz.covid19cz.erouska.ext.hideKeyboard
import cz.covid19cz.erouska.ext.show
import cz.covid19cz.erouska.ext.showWeb
import cz.covid19cz.erouska.ui.base.BaseFragment
import cz.covid19cz.erouska.ui.dashboard.event.GmsApiErrorEvent
import cz.covid19cz.erouska.utils.CustomTabHelper
import cz.covid19cz.erouska.utils.SupportEmailGenerator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.fragment_activation.*
import javax.inject.Inject
@AndroidEntryPoint
class ActivationFragment :
BaseFragment<FragmentActivationBinding, ActivationVM>(
R.layout.fragment_activation,
ActivationVM::class
) {
companion object {
private const val SCREEN_NAME = "Activation"
}
@Inject
internal lateinit var customTabHelper: CustomTabHelper
@Inject
internal lateinit var exposureNotificationsErrorHandling: ExposureNotificationsErrorHandling
@Inject
internal lateinit var supportEmailGenerator: SupportEmailGenerator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
subscribe(GmsApiErrorEvent::class) {
exposureNotificationsErrorHandling.handle(it, this, SCREEN_NAME)
}
}
override fun onStart() {
super.onStart()
viewModel.state.observe(this) {
updateState(it)
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.onboarding, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
val handled = onBackPressed()
return if (handled) {
true
} else {
super.onOptionsItemSelected(item)
}
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupListeners()
enableUpInToolbar(true)
privacy_body_2.text = HtmlCompat.fromHtml(
getString(R.string.privacy_body_text_2, AppConfig.conditionsOfUseUrl),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
}
private fun setupListeners() {
privacy_body_2.setOnClickListener { showWeb(AppConfig.conditionsOfUseUrl, customTabHelper) }
activate_btn.setOnClickListener { viewModel.activate() }
}
private fun updateState(state: ActivationState) {
when (state) {
is ActivationStart -> onActivationStart()
is ActivationFinished -> onActivationSuccess()
is ActivationFailed -> onActivationFailed(state.errorMessage)
is ActivationInit -> onActivationInit()
is NoInternet -> onNoInternet()
}
}
private fun showSignedIn() {
if (navController().currentDestination?.id == R.id.nav_activation) {
navigate(
R.id.action_nav_activation_to_nav_dashboard,
null,
Builder().setPopUpTo(R.id.nav_graph, true).build()
)
}
}
private fun onActivationStart() {
login_progress.show()
activate_btn.hide()
privacy_group.hide()
error_group.hide()
}
private fun onActivationSuccess() {
showSignedIn()
}
private fun onNoInternet() {
showSnackBar(R.string.no_internet)
}
private fun onActivationInit() {
activity?.setTitle(R.string.privacy_toolbar_title)
login_progress.hide()
activate_btn.show()
privacy_group.show()
error_group.hide()
}
private fun onActivationFailed(errorMessage: String?) {
activity?.setTitle(R.string.error_title)
error_body.text =
getString(R.string.send_data_failure_body, AppConfig.supportEmail, errorMessage)
email_button.setOnClickListener {
supportEmailGenerator.sendSupportEmail(
requireActivity(),
lifecycleScope,
errorCode = errorMessage,
isError = true,
screenOrigin = SCREEN_NAME
)
}
login_progress.hide()
privacy_group.hide()
activate_btn.hide()
error_group.show()
}
private fun goBack() {
view?.hideKeyboard()
viewModel.backPressed()
}
override fun onBackPressed(): Boolean {
return if (viewModel.state.value is ActivationFailed) {
goBack()
true
} else false
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
ExposureNotificationsErrorHandling.REQUEST_GMS_ERROR_RESOLUTION -> {
if (resultCode == Activity.RESULT_OK) {
viewModel.activate()
}
}
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationNotificationsFragment.kt
================================================
package cz.covid19cz.erouska.ui.activation
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.databinding.FragmentActivationNotificationsBinding
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsErrorHandling
import cz.covid19cz.erouska.ui.base.BaseFragment
import cz.covid19cz.erouska.ui.dashboard.event.BluetoothDisabledEvent
import cz.covid19cz.erouska.ui.dashboard.event.GmsApiErrorEvent
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ActivationNotificationsFragment :
BaseFragment<FragmentActivationNotificationsBinding, ActivationNotificationsVM>(
R.layout.fragment_activation_notifications,
ActivationNotificationsVM::class
) {
companion object{
private const val SCREEN_NAME = "Activation Notifications"
}
@Inject
internal lateinit var exposureNotificationsErrorHandling: ExposureNotificationsErrorHandling
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
subscribe(NotificationsVerifiedEvent::class) {
toEfgs()
}
subscribe(GmsApiErrorEvent::class) {
exposureNotificationsErrorHandling.handle(it, this, SCREEN_NAME)
}
subscribe(BluetoothDisabledEvent::class) {
requestEnableBt()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
enableUpInToolbar(true)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
ExposureNotificationsErrorHandling.REQUEST_GMS_ERROR_RESOLUTION -> {
if (resultCode == Activity.RESULT_OK) {
viewModel.enableNotifications()
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.onboarding, menu)
super.onCreateOptionsMenu(menu, inflater)
}
private fun toEfgs() {
navigate(ActivationNotificationsFragmentDirections.actionNavActivationNotificationsToEfgsUpdate(onboarding = true, fullscreen = true))
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationNotificationsVM.kt
================================================
package cz.covid19cz.erouska.ui.activation
import androidx.lifecycle.viewModelScope
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsRepository
import cz.covid19cz.erouska.ui.base.BaseVM
import cz.covid19cz.erouska.ui.dashboard.event.GmsApiErrorEvent
import cz.covid19cz.erouska.utils.L
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ActivationNotificationsVM @Inject constructor(
private val exposureNotificationsRepository: ExposureNotificationsRepository
) : BaseVM() {
fun enableNotifications() {
viewModelScope.launch {
runCatching {
if (!exposureNotificationsRepository.isEnabled()){
exposureNotificationsRepository.start()
L.d("Exposure Notifications started")
}
}.onSuccess {
publish(NotificationsVerifiedEvent)
}.onFailure {
publish(GmsApiErrorEvent(it))
}
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationState.kt
================================================
package cz.covid19cz.erouska.ui.activation
import arch.event.LiveEvent
sealed class ActivationState
object ActivationStart : ActivationState()
object ActivationFinished : ActivationState()
data class ActivationFailed(val errorMessage: String?) : ActivationState()
object ActivationInit : ActivationState()
object NoInternet : ActivationState()
object NotificationsVerifiedEvent : LiveEvent()
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationVM.kt
================================================
package cz.covid19cz.erouska.ui.activation
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.google.android.gms.common.api.ApiException
import com.google.firebase.auth.FirebaseAuth
import cz.covid19cz.erouska.exposurenotifications.Notifications
import cz.covid19cz.erouska.ext.isNetworkAvailable
import cz.covid19cz.erouska.net.FirebaseFunctionsRepository
import cz.covid19cz.erouska.ui.base.BaseVM
import cz.covid19cz.erouska.ui.dashboard.event.GmsApiErrorEvent
import cz.covid19cz.erouska.utils.L
import cz.covid19cz.erouska.utils.LocaleUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ActivationVM @Inject constructor(
private val firebaseFunctionsRepository: FirebaseFunctionsRepository,
@ApplicationContext
private val context: Context,
private val notifications: Notifications
) : BaseVM() {
private val mutableState = MutableLiveData<ActivationState>()
val state = mutableState as LiveData<ActivationState>
private val auth: FirebaseAuth = FirebaseAuth.getInstance()
init {
auth.setLanguageCode(LocaleUtils.getSupportedLanguage())
if (auth.currentUser != null) {
mutableState.postValue(ActivationFinished)
}
}
fun activate() {
if (context.isNetworkAvailable()) {
viewModelScope.launch(Dispatchers.IO) {
mutableState.postValue(ActivationStart)
try {
firebaseFunctionsRepository.register(notifications.getCurrentPushToken())
mutableState.postValue(ActivationFinished)
} catch (e: Exception) {
if (e is ApiException) {
publish(GmsApiErrorEvent(e))
return@launch
}
L.e(e)
mutableState.postValue(ActivationFailed(e.message))
}
}
} else {
mutableState.postValue(NoInternet)
}
}
fun backPressed() {
mutableState.postValue(ActivationInit)
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/base/BaseActivity.kt
================================================
package cz.covid19cz.erouska.ui.base
import android.app.Activity
import android.content.Intent
import android.content.IntentSender
import androidx.databinding.ViewDataBinding
import arch.view.BaseArchActivity
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.UpdateAvailability
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.BuildConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.utils.L
import kotlin.reflect.KClass
open class BaseActivity<B : ViewDataBinding, VM : BaseVM>(
layoutId: Int,
viewModelClass: KClass<VM>
) :
BaseArchActivity<B, VM>(layoutId, viewModelClass) {
override fun onBackPressed() {
val childFragmentManager =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment)?.childFragmentManager
if ((childFragmentManager?.fragments?.getOrNull(0) as? BaseFragment<*, *>)?.onBackPressed() != true) {
super.onBackPressed()
}
}
override fun onResume() {
super.onResume()
updateIfNeeded()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == APP_UPDATE_REQUEST_CODE) {
if (resultCode != Activity.RESULT_OK) {
updateIfNeeded()
}
}
super.onActivityResult(requestCode, resultCode, data)
}
private fun updateIfNeeded() {
if (isObsolete()) {
checkForAppUpdate()
}
}
private fun isObsolete(): Boolean {
return BuildConfig.VERSION_CODE < AppConfig.minSupportedVersionCodeAndroid
}
private fun checkForAppUpdate() {
val appUpdateManager = AppUpdateManagerFactory.create(this)
appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
try {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
AppUpdateType.IMMEDIATE,
this,
APP_UPDATE_REQUEST_CODE
)
} catch (e: IntentSender.SendIntentException) {
L.e(e)
}
}
}
}
companion object {
const val APP_UPDATE_REQUEST_CODE = 1777
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/base/BaseFragment.kt
================================================
package cz.covid19cz.erouska.ui.base
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.MenuItem
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.pm.PackageInfoCompat
import androidx.databinding.ViewDataBinding
import arch.view.BaseArchFragment
import arch.viewmodel.BaseArchViewModel
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.material.snackbar.Snackbar
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.utils.L
import kotlin.reflect.KClass
abstract class BaseFragment<B : ViewDataBinding, VM : BaseArchViewModel>(
layoutId: Int,
viewModelClass: KClass<VM>
) : BaseArchFragment<B, VM>(layoutId, viewModelClass) {
companion object {
const val REQUEST_BT_ENABLE = 1000
}
var snackBar: Snackbar? = null
protected open fun showSnackBar(@StringRes stringRes: Int) {
showSnackBar(getString(stringRes))
}
protected open fun showSnackBarForever(@StringRes stringRes: Int) {
showSnackBarForever(getString(stringRes))
}
protected open fun showSnackBar(@StringRes stringRes: Int, vararg args: Any) {
showSnackBar(getString(stringRes, args))
}
protected open fun showSnackBar(text: String) {
showSnackBarWithDuration(text, Snackbar.LENGTH_LONG)
}
protected open fun showSnackBarForever(text: String) {
showSnackBarWithDuration(text, Snackbar.LENGTH_INDEFINITE)
}
protected open fun hideSnackBar() {
snackBar?.dismiss()
snackBar = null
}
private fun showSnackBarWithDuration(text: String, length: Int) {
view?.let {
if (snackBar == null) {
snackBar = Snackbar.make(it, text, length)
} else {
snackBar?.setText(text)
}
snackBar?.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
return navController().navigateUp()
}
else -> super.onOptionsItemSelected(item)
}
}
open fun onBluetoothEnabled() {
// stub
}
fun enableUpInToolbar(enable: Boolean, iconType: IconType = IconType.UP) {
(activity as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(enable)
if (enable) {
when (iconType) {
IconType.CLOSE -> R.drawable.ic_action_close
IconType.UP -> R.drawable.ic_action_up
}.run {
(activity as AppCompatActivity).supportActionBar?.setHomeAsUpIndicator(this)
}
}
}
fun setTitle(@StringRes res: Int) {
setTitle(getString(res))
}
fun setTitle(title: String) {
(activity as AppCompatActivity).supportActionBar?.title = title
}
fun requestEnableBt() {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_BT_ENABLE)
}
fun requestLocationEnable() {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
startActivity(intent)
}
fun isPlayServicesObsolete(): Boolean {
return try {
val current = PackageInfoCompat.getLongVersionCode(
requireContext().packageManager.getPackageInfo(
GoogleApiAvailability.GOOGLE_PLAY_SERVICES_PACKAGE,
0
)
)
current < AppConfig.minGmsVersionCode
} catch (e: Exception) {
L.e(e)
true
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_BT_ENABLE -> {
if (resultCode == Activity.RESULT_OK) {
onBluetoothEnabled()
}
}
}
super.onActivityResult(requestCode, resultCode, data)
}
open fun onBackPressed(): Boolean {
return false
}
enum class IconType {
UP, CLOSE
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/base/BaseVM.kt
================================================
package cz.covid19cz.erouska.ui.base
import arch.viewmodel.BaseArchViewModel
import cz.covid19cz.erouska.ext.execute
import io.reactivex.Flowable
import io.reactivex.Maybe
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
open class BaseVM : BaseArchViewModel() {
private val disposables = CompositeDisposable()
fun <T> subscribe(observable: Observable<T>, onError: (Throwable) -> Unit, onNext: (T) -> Unit) : Disposable {
val disposable = observable.execute(onNext, onError)
disposables.add(disposable)
return disposable
}
fun <T> subscribe(observable: Flowable<T>, onError: (Throwable) -> Unit, onNext: (T) -> Unit) : Disposable {
val disposable = observable.execute(onNext, onError)
disposables.add(disposable)
return disposable
}
fun <T> subscribe(single: Single<T>, onError: (Throwable) -> Unit, onNext: (T) -> Unit) : Disposable {
val disposable = single.execute(onNext, onError)
disposables.add(disposable)
return disposable
}
fun <T> subscribe(maybe: Maybe<T>, onError: (Throwable) -> Unit, onSuccess: (T) -> Unit) : Disposable {
val disposable = maybe.execute(onSuccess, onError)
disposables.add(disposable)
return disposable
}
override fun onCleared() {
disposables.dispose()
super.onCleared()
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/base/UrlEvent.kt
================================================
package cz.covid19cz.erouska.ui.base
import arch.event.LiveEvent
class UrlEvent(val url : String) : LiveEvent()
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/Contact.kt
================================================
package cz.covid19cz.erouska.ui.contacts
data class Contact(
val title: String,
val text: String,
val linkTitle: String,
val link: String
)
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/ContactsFragment.kt
================================================
package cz.covid19cz.erouska.ui.contacts
import android.os.Bundle
import android.view.View
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.databinding.FragmentContactsBinding
import cz.covid19cz.erouska.ext.showWeb
import cz.covid19cz.erouska.ui.base.BaseFragment
import cz.covid19cz.erouska.ui.contacts.event.ContactsEvent
import cz.covid19cz.erouska.utils.CustomTabHelper
import cz.covid19cz.erouska.utils.SupportEmailGenerator
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ContactsFragment : BaseFragment<FragmentContactsBinding, ContactsVM>(
R.layout.fragment_contacts,
ContactsVM::class
) {
companion object {
private const val SCREEN_NAME = "Contacts"
}
@Inject
internal lateinit var customTabHelper: CustomTabHelper
@Inject
internal lateinit var supportEmailGenerator: SupportEmailGenerator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.state.observe(this) { processEvent(it) }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
enableUpInToolbar(false)
}
private fun processEvent(event: ContactsEvent) {
if (event is ContactsEvent.ContactLinkClicked) {
val link = event.link
if (link.startsWith("mailto:")) {
supportEmailGenerator.sendSupportEmail(
requireActivity(),
lifecycleScope,
recipient = link.split("mailto:")[1],
isError = false,
screenOrigin = SCREEN_NAME
)
} else {
showWeb(link, customTabHelper)
}
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/ContactsVM.kt
================================================
package cz.covid19cz.erouska.ui.contacts
import androidx.databinding.ObservableArrayList
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.ui.base.BaseVM
import cz.covid19cz.erouska.ui.contacts.event.ContactsEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import java.lang.reflect.Type
import javax.inject.Inject
@HiltViewModel
class ContactsVM @Inject constructor() : BaseVM() {
val state = MutableLiveData<ContactsEvent>()
val items = ObservableArrayList<Contact>()
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun onCreate() {
val contactsListType: Type = object : TypeToken<ArrayList<Contact>?>() {}.type
val contacts: ArrayList<Contact> = Gson().fromJson(AppConfig.contactsContentJson, contactsListType)
items.clear()
items.addAll(contacts)
}
fun onLinkClick(link: String) {
state.value = ContactsEvent.ContactLinkClicked(link)
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/event/ContactsCommandEvent.kt
================================================
package cz.covid19cz.erouska.ui.contacts.event
sealed class ContactsEvent {
data class ContactLinkClicked(val link: String) : ContactsEvent()
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/DashboardCardView.kt
================================================
package cz.covid19cz.erouska.ui.dashboard
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.ext.hide
import cz.covid19cz.erouska.ext.show
import kotlinx.android.synthetic.main.dashboard_card_view.view.*
import kotlinx.android.synthetic.main.view_data_item.view.subtitle_text
import kotlinx.android.synthetic.main.view_data_item.view.title_text
open class DashboardCardView : ConstraintLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, attributeSetId: Int) : super(
context,
attrs,
attributeSetId
)
init {
View.inflate(context, getLayoutId(), this)
}
open fun getLayoutId() = R.layout.dashboard_card_view
var card_title: String? = null
set(value) {
field = value
title_text.text = value
}
var card_subtitle: String? = null
set(value) {
field = value
subtitle_text.text = value
if (!value.isNullOrBlank()) {
subtitle_text.show()
} else {
subtitle_text.hide()
}
}
var card_button_text: String? = null
set(value) {
field = value
button.text = value
button.show()
}
open var card_show_right_arrow: Boolean? = false
set(value) {
field = value
if (value == true) {
title_text.setCompoundDrawablesWithIntrinsicBounds(card_icon, null, ContextCompat.getDrawable(context, R.drawable.ic_arrow_right), null)
} else {
title_text.setCompoundDrawablesWithIntrinsicBounds(card_icon, null, null, null)
}
}
var card_on_content_click: OnClickListener? = null
set(value) {
field = value
content_container.setOnClickListener(value)
content_container.isFocusable = true
content_container.isClickable = true
}
var card_actionable_button: Boolean? = false
set(value) {
field = value
if (value == true) {
button.show()
} else {
button.hide()
}
}
var card_on_button_click: OnClickListener? = null
set(value) {
field = value
card_actionable_button = value != null
button.setOnClickListener(value)
}
var card_has_content: Boolean? = true
set(value) {
field = value
if (value == true) {
subtitle_text.show()
} else {
subtitle_text.hide()
}
}
var card_alert: Boolean? = false
set(value) {
field = value
if (value == true) {
title_text.setTextColor(ContextCompat.getColor(context, R.color.exposure_notification_red))
} else {
title_text.setTextColor(ContextCompat.getColor(context, R.color.textColorPrimary))
}
}
open var card_icon: Drawable? = null
set(value) {
field = value
value?.let {
title_text.setCompoundDrawablesWithIntrinsicBounds(it, null, if (card_show_right_arrow == true) ContextCompat.getDrawable(context, R.drawable.ic_arrow_right) else null , null)
}
}
}
================================================
FILE: app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/DashboardFragment.kt
================================================
package cz.covid19cz.erouska.ui.dashboard
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.observe
import com.tbruyelle.rxpermissions2.RxPermissions
import cz.covid19cz.erouska.AppConfig
import cz.covid19cz.erouska.BuildConfig
import cz.covid19cz.erouska.R
import cz.covid19cz.erouska.databinding.FragmentDashboardPlusBinding
import cz.covid19cz.erouska.exposurenotifications.ExposureNotificationsErrorHandling
import cz.covid19cz.erouska.exposurenotifications.Notifications
import cz.covid19cz.erouska.ext.*
import cz.covid19cz.erouska.ui.base.BaseFragment
import cz.covid19cz.erouska.ui.dashboard.event.DashboardCommandEvent
import cz.covid19cz.erouska.ui.dashboard.event.GmsApiErrorEvent
import cz.covid19cz.erouska.ui.exposure.event.ExposuresCommandEvent
import cz.covid19cz.erouska.ui.main.MainVM
import cz.covid19cz.erouska.utils.Analytics
import cz.covid19cz.erouska.utils.Analytics.KEY_PAUSE_APP
import cz.covid19cz.erouska.utils.Analytics.KEY_RESUME_APP
import cz.covid19cz.erouska.utils.Analytics.KEY_SHARE_APP
import cz.covid19cz.erouska.utils.showOrHide
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.fragment_dashboard_plus.*
import javax.inject.Inject
@AndroidEntryPoint
class DashboardFragment : BaseFragment<FragmentDashboardPlusBinding, DashboardVM>(
R.layout.fragment_dashboard_plus,
DashboardVM::class
) {
companion object {
private const val SCREEN_NAME = "Dashboard"
}
private val mainViewModel: MainVM by activityViewModels()
@Inject
lateinit var notifications: Notifications
@Inject
internal lateinit var exposureNotificationsErrorHandling: ExposureNotificationsErrorHandling
private lateinit var rxPermissions: RxPermissions
private var demoMode = false
private val btAndLocationReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
context?.let {
viewModel.checkStatus()
refreshDotIndicator(context)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity?.setTitle(R.string.app_name)
rxPermissions = RxPermissions(this)
subscribeToViewModel()
viewModel.exposureNotificationsEnabled.observe(this, Observer { isEnabled ->
refreshDotIndicator(requireContext())
if (isEnabled) {
notifications.dismissNotRunningNotification()
}
checkAppActive()
})
viewModel.bluetoothState.observe(
this,
Observer { isEnabled -> onBluetoothStateChanged(isEnabled) })
viewModel.locationState.observe(
this,
Observer { isEnabled -> onLocationStateChanged(isEnabled) })
viewModel.lastUpdateTimestamp.observe(this, { updateLastUpdateDateAndTime() })
viewModel.lastExposureDate.observe(this, { updateLastUpdateDateAndTime() })
}
override fun onStart() {
super.onStart()
val intentFilter = IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
// don't register Location Receiver on devices with Android 11+ (it's not mandatory to have location services turned on)
// https://developer.android.com/about/versions/11/behavior-changes-all#exposure-notifications
// https://developers.google.com/android/exposure-notifications/implementation-guide#locationless_scanning_in_android_11
if (!viewModel.isLocationlessScanSupported()) {
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
}
}
context?.registerReceiver(
btAndLocationReceiver,
intentFilter
)
}
override fun onStop() {
context?.unregisterReceiver(btAndLocationReceiver)
super.onStop()
}
private fun refreshDotIndicator(context: Context) {
mainViewModel.serviceRunning.value =
viewModel.exposureNotificationsEnabled.value &&
context.isBtEnabled() &&
(viewModel.isLocationlessScanSupported() || context.isLocationEnabled())
}
private fun subscribeToViewModel() {
subscribe(DashboardCommandEvent::class) { commandEvent ->
when (commandEvent.command) {
DashboardCommandEvent.Command.DATA_UP_TO_DATE -> {
notifications.dismissOudatedDataNotification()
showOrHideDataNotification(false)
}
DashboardCommandEvent.Command.SHOW_HOW_IT_WORKS -> updateOnboardingNotif(true)
DashboardCommandEvent.Command.HIDE_HOW_IT_WORKS -> updateOnboardingNotif(false)
DashboardCommandEvent.Command.DATA_OBSOLETE -> showOrHideDataNotification(true)
DashboardCommandEvent.Command.RECENT_EXPOSURE -> showOrHideExposureNotification(true)
DashboardCommandEvent.Command.NOT_ACTIVATED -> showWelcomeScreen()
DashboardCommandEvent.Command.EFGS -> showEfgs()
DashboardCommandEvent.Command.TURN_OFF -> notifications.showErouskaPausedNotification()
}
}
subscribe(GmsApiErrorEvent::class) {
exposureNotificationsErrorHandling.handle(it, this, SCREEN_NAME)
}
subscribe(ExposuresCommandEvent::class) {
when (it.command) {
ExposuresCommandEvent.Command.RECENT_EXPOSURE -> onRecentExposureDiscovered()
ExposuresCommandEvent.Command.NO_RECENT_EXPOSURES -> onNoExposureDiscovered()
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (viewModel.shouldIntroduceEFGS()) {
navigate(DashboardFragmentDirections.actionNavDashboardToNavEfgsUpdate(fullscreen = true))
}
exposure_notification_content.text = AppConfig.encounterWarning
exposure_notification_close.setOnClickListener {
viewModel.acceptExposure()
showOrHideExposureNotification(false)
}
exposure_notification_more_info.setOnClickListener { viewModel.showExposureDetail() }
data_notification_close.setOnClickListener { showOrHideDataNotification(false) }
how_it_works_more.setOnClickListener { viewModel.showHowItWorksPage() }
how_it_works_close.setOnClickListener { viewModel.dismissHowItWorksNotification() }
enableUpInToolbar(false)
data_notification_content.text = AppConfig.recentExposureNotificationTitle
dash_card_no_risky_encounter.card_title = AppConfig.noEncounterCardTitle
dash_bluetooth_off.card_on_content_click = View.OnClickListener { requestEnableBt() }
dash_location_off.card_on_content_click = View.OnClickListener { requestLocationEnable() }
dash_card_risky_encounter.card_on_content_click =
View.OnClickListener { viewModel.showExposureDetail() }
dash_card_no_risky_encounter.card_on_content_click =
View.OnClickListener { viewModel.showExposureDetail() }
dash_card_positive_test.card_on_content_click =
View.OnClickListener { viewModel.sendData() }
dash_travel.card_on_content_click =
View.OnClickListener { viewModel.showEfgs() }
exposure_notification_content.text = AppConfig.encounterWarning
exposure_notification_more_info.setOnClickListener { viewModel.showExposureDetail() }
exposure_notification_close.setOnClickListener {
viewModel.acceptExposure()
exposure_notification_container.hide()
}
dash_card_active.setOnClickListener {
viewModel.stop()
Analytics.logEvent(requireContext(), KEY_PAUSE_APP)
}
dash_card_inactive.setOnClickListener {
viewModel.start()
Analytics.logEvent(requireContext(), KEY_RESUME_APP)
}
updateLastUpdateDateAndTime()
viewModel.cancelSuppression()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.dashboard, menu)
if (BuildConfig.FLAVOR == "dev") {
menu.add(0, R.id.action_activation, 11, "Test Aktivace")
menu.add(0, R.id.action_exposure_demo, 12, "Test Riz. Notifikace")
menu.add(0, R.id.action_play_services, 13, "Test PlayServices")
menu.add(0, R.id.action_sandbox, 14, "Test Sandbox")
menu.add(0, R.id.action_efgs, 15, "Test EFGS")
menu.add(0, R.id.action_exposure_screen, 17, "Test Exposure screen")
menu.add(0, R.id.action_exposure_info, 18, "Test Rizikové setkání")
menu.add(0, R.id.action_efgs_control, 19, "Test EFGS Control")
}
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_share -> {
requireContext().shareApp()
Analytics.logEvent(requireContext(), KEY_SHARE_APP)
true
}
R.id.nav_about -> {
navigate(R.id.nav_about)
true
}
R.id.action_sandbox -> {
navigate(R.id.nav_sandbox)
true
}
R.id.action_efgs -> {
navigate(DashboardFragmentDirections.actionNavDashboardToNavEfgsUpdate(fullscreen = true))
true
}
R.id.action_activation -> {
viewModel.unregister()
showWelcomeScreen()
true
}
R.id.action_exposure_demo -> {
demoMode = true
showOrHideExposureNotification(true)
true
}
R.id.action_exposure_screen -> {
navigate(DashboardFragmentDirections.actionNavDashboardToNavExposure(demo = true))
true
}
R.id.action_play_services -> {
showPlayServicesUpdate()
true
}
R.id.action_efgs_control -> {
navigate(DashboardFragmentDirections.actionNavDashboardToNavEfgs())
true
}
R.id.action_exposure_info -> {
navigate(DashboardFragmentDirections.actionNavDashboardToNavExposureInfo(demo = true))
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
ExposureNotificationsErrorHandling.REQUEST_GMS_ERROR_RESOLUTION -> {
if (resultCode == Activity.RESULT_OK) {
viewModel.start()
}
}
}
}
private fun updateLastUpdateDateAndTime() {
val lastUpdateTimestamp = viewModel.lastUpdateTimestamp.value
val lastUpdateString = lastUpdateTimestamp?.let {
if (it != 0L) {
resources.getString(
R.string.dashboard_body_no_contact,
it.timestampToDate(), it.timestampToTime()
)
} else null
} ?: resources.getString(R.string.dashboard_loading_data)
val text = lastUpdateString + "\n${AppConfig.encounterUpdateFrequency}"
dash_card_no_risky_encounter.card_subtitle = text
updateLastUpdateOnExposureCard(lastUpdateString)
}
private fun updateLastUpdateOnExposureCard(lastUpdateString: String?) {
val lastExposureString = resources.getString(
R.string.dashboard_risky_encounter_subtitle_bad,
viewModel.lastExposureDate.value
)
val text = lastUpdateString?.let {
"${lastExposureString}\n\n${lastUpdateString}"
} ?: lastExposureString
dash_card_risky_encounter.card_subtitle = text
}
private fun onRecentExposureDiscovered() {
dash_card_no_risky_encounter.hide()
dash_card_risky_encounter.show()
}
private fun onNoExposureDiscovered() {
dash_card_no_risky_encounter.show()
dash_card_risky_encounter.hide()
}
private fun onBluetoothStateChanged(isEnabled: Boolean) {
dash_bluetooth_off.showOrHide(!isEnabled)
checkAppActive()
}
private fun onLocationStateChanged(isEnabled: Boolean) {
// Location services don't need to be turned on on devices with Android 11+
if (viewModel.isLocationlessScanSupported()) {
dash_location_off.hide()
} else {
dash_location_off.showOrHide(!isEnabled)
}
checkAppActive()
}
private fun checkAppActive() {
val enEnabled = viewModel.exposureNotificationsEnabled.value
// Location services don't need to be turned on on devices with
gitextract_yznxotpf/ ├── .github/ │ ├── pull_request_template.txt │ ├── stale.yml │ └── workflows/ │ ├── deploy-develop.yml │ └── deploy-master.yml ├── .gitignore ├── .idea/ │ └── codeStyles/ │ └── codeStyleConfig.xml ├── LICENSE ├── README.md ├── app/ │ ├── build.gradle │ ├── libs/ │ │ └── play-services-nearby-exposurenotification-1.7.2-eap.aar │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── kotlin/ │ │ └── cz/ │ │ └── covid19cz/ │ │ └── erouska/ │ │ ├── helpers/ │ │ │ ├── Actions.kt │ │ │ ├── ClickableLink.kt │ │ │ └── TextMatchesIgnoringWhitespaceType.kt │ │ ├── screens/ │ │ │ ├── A1Screen.kt │ │ │ ├── A2Screen.kt │ │ │ ├── A3Screen.kt │ │ │ ├── B1Screen.kt │ │ │ └── N1Screen.kt │ │ ├── testRules/ │ │ │ └── DisableAnimationsRule.kt │ │ └── tests/ │ │ └── ActivationTest.kt │ ├── dev/ │ │ ├── google-services.json │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_launcher_background.xml │ │ │ └── ic_launcher_foreground.xml │ │ └── values/ │ │ ├── controls.xml │ │ └── strings.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── kotlin/ │ │ │ └── cz/ │ │ │ └── covid19cz/ │ │ │ └── erouska/ │ │ │ ├── App.kt │ │ │ ├── AppConfig.kt │ │ │ ├── DI.kt │ │ │ ├── db/ │ │ │ │ ├── DailySummariesDb.kt │ │ │ │ ├── DailySummaryDao.kt │ │ │ │ ├── DailySummaryEntity.kt │ │ │ │ └── SharedPrefsRepository.kt │ │ │ ├── exposurenotifications/ │ │ │ │ ├── ExposureCryptoTools.kt │ │ │ │ ├── ExposureNotificationsErrorHandling.kt │ │ │ │ ├── ExposureNotificationsRepository.kt │ │ │ │ ├── Notifications.kt │ │ │ │ ├── receiver/ │ │ │ │ │ └── ExposureNotificationBroadcastReceiver.kt │ │ │ │ ├── service/ │ │ │ │ │ └── PushService.kt │ │ │ │ └── worker/ │ │ │ │ ├── DownloadKeysWorker.kt │ │ │ │ └── SelfCheckerWorker.kt │ │ │ ├── ext/ │ │ │ │ ├── ByteArray.kt │ │ │ │ ├── Context.kt │ │ │ │ ├── Int.kt │ │ │ │ ├── Long.kt │ │ │ │ ├── Rx.kt │ │ │ │ ├── String.kt │ │ │ │ └── View.kt │ │ │ ├── net/ │ │ │ │ ├── ExposureServerRepository.kt │ │ │ │ ├── FirebaseFunctionsRepository.kt │ │ │ │ ├── api/ │ │ │ │ │ ├── KeyServerApi.kt │ │ │ │ │ └── VerificationServerApi.kt │ │ │ │ ├── exception/ │ │ │ │ │ └── UnauthrorizedException.kt │ │ │ │ └── model/ │ │ │ │ ├── CovidDataModel.kt │ │ │ │ ├── DownloadedKeys.kt │ │ │ │ ├── KeyServerModel.kt │ │ │ │ └── VerificationServerModel.kt │ │ │ ├── ui/ │ │ │ │ ├── about/ │ │ │ │ │ ├── AboutFragment.kt │ │ │ │ │ ├── AboutVM.kt │ │ │ │ │ └── entity/ │ │ │ │ │ └── AboutProfileItem.kt │ │ │ │ ├── activation/ │ │ │ │ │ ├── ActivationFragment.kt │ │ │ │ │ ├── ActivationNotificationsFragment.kt │ │ │ │ │ ├── ActivationNotificationsVM.kt │ │ │ │ │ ├── ActivationState.kt │ │ │ │ │ └── ActivationVM.kt │ │ │ │ ├── base/ │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ ├── BaseFragment.kt │ │ │ │ │ ├── BaseVM.kt │ │ │ │ │ └── UrlEvent.kt │ │ │ │ ├── contacts/ │ │ │ │ │ ├── Contact.kt │ │ │ │ │ ├── ContactsFragment.kt │ │ │ │ │ ├── ContactsVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── ContactsCommandEvent.kt │ │ │ │ ├── dashboard/ │ │ │ │ │ ├── DashboardCardView.kt │ │ │ │ │ ├── DashboardFragment.kt │ │ │ │ │ ├── DashboardVM.kt │ │ │ │ │ ├── TravellerDashboardCardView.kt │ │ │ │ │ └── event/ │ │ │ │ │ ├── DashboardCommandEvent.kt │ │ │ │ │ ├── DisabledEvent.kt │ │ │ │ │ └── GmsApiErrorEvent.kt │ │ │ │ ├── efgs/ │ │ │ │ │ ├── EfgsFragment.kt │ │ │ │ │ ├── EfgsVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── EfgsCommandEvent.kt │ │ │ │ ├── efgsagreement/ │ │ │ │ │ ├── EfgsAgreementFragment.kt │ │ │ │ │ └── EfgsAgreementVM.kt │ │ │ │ ├── error/ │ │ │ │ │ ├── ErrorFragment.kt │ │ │ │ │ ├── ErrorVM.kt │ │ │ │ │ └── entity/ │ │ │ │ │ └── ErrorType.kt │ │ │ │ ├── exposure/ │ │ │ │ │ ├── ExposureFragment.kt │ │ │ │ │ ├── ExposureVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── ExposuresEvent.kt │ │ │ │ ├── exposurehelp/ │ │ │ │ │ ├── ExposureHelpFragment.kt │ │ │ │ │ ├── ExposureHelpVM.kt │ │ │ │ │ └── entity/ │ │ │ │ │ ├── ExposureHelpData.kt │ │ │ │ │ ├── ExposureHelpItem.kt │ │ │ │ │ ├── ExposureHelpTitle.kt │ │ │ │ │ └── ExposureHelpType.kt │ │ │ │ ├── exposureinfo/ │ │ │ │ │ ├── ExposureInfoFragment.kt │ │ │ │ │ └── ExposureInfoVM.kt │ │ │ │ ├── help/ │ │ │ │ │ ├── HelpFragment.kt │ │ │ │ │ ├── HelpVM.kt │ │ │ │ │ └── data/ │ │ │ │ │ ├── AboutAppCategory.kt │ │ │ │ │ ├── Category.kt │ │ │ │ │ ├── FaqCategory.kt │ │ │ │ │ ├── HowToCategory.kt │ │ │ │ │ └── Question.kt │ │ │ │ ├── helpcategory/ │ │ │ │ │ ├── HelpCategoryFragment.kt │ │ │ │ │ └── HelpCategoryVM.kt │ │ │ │ ├── helpquestion/ │ │ │ │ │ ├── HelpQuestionFragment.kt │ │ │ │ │ └── HelpQuestionVM.kt │ │ │ │ ├── helpsearch/ │ │ │ │ │ ├── HelpSearchFragment.kt │ │ │ │ │ ├── HelpSearchVM.kt │ │ │ │ │ ├── data/ │ │ │ │ │ │ └── SearchableQuestion.kt │ │ │ │ │ └── ui/ │ │ │ │ │ └── SearchableItem.kt │ │ │ │ ├── how/ │ │ │ │ │ ├── HowItWorksFragment.kt │ │ │ │ │ ├── HowItWorksVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── HowItWorksEvent.kt │ │ │ │ ├── main/ │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ ├── MainActivityOld.kt │ │ │ │ │ └── MainVM.kt │ │ │ │ ├── mydata/ │ │ │ │ │ ├── CaseItemView.kt │ │ │ │ │ ├── MyDataFragment.kt │ │ │ │ │ └── MyDataVM.kt │ │ │ │ ├── noverificationcode/ │ │ │ │ │ ├── NoVerificationCodeFragment.kt │ │ │ │ │ ├── NoVerificationCodeVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── NoVerificationCodeEvent.kt │ │ │ │ ├── permissions/ │ │ │ │ │ └── BasePermissionsFragment.kt │ │ │ │ ├── publishsuccess/ │ │ │ │ │ ├── PublishSuccessFragment.kt │ │ │ │ │ └── PublishSuccessVM.kt │ │ │ │ ├── ragnarok/ │ │ │ │ │ └── RagnarokVM.kt │ │ │ │ ├── recentexposures/ │ │ │ │ │ ├── RecentExposuresFragment.kt │ │ │ │ │ ├── RecentExposuresVM.kt │ │ │ │ │ └── entity/ │ │ │ │ │ └── RecentExposureGroupHeaderItem.kt │ │ │ │ ├── sandbox/ │ │ │ │ │ ├── SandboxConfigFragment.kt │ │ │ │ │ ├── SandboxConfigVM.kt │ │ │ │ │ ├── SandboxConfigValues.kt │ │ │ │ │ ├── SandboxDataFragment.kt │ │ │ │ │ ├── SandboxDataVM.kt │ │ │ │ │ ├── SandboxFragment.kt │ │ │ │ │ ├── SandboxVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── SnackbarEvent.kt │ │ │ │ ├── symptomdate/ │ │ │ │ │ ├── SymptomDateFragment.kt │ │ │ │ │ ├── SymptomDateVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ ├── DatePickerEvent.kt │ │ │ │ │ └── SymptomDateCommandEvent.kt │ │ │ │ ├── traveller/ │ │ │ │ │ ├── TravellerFragment.kt │ │ │ │ │ └── TravellerVM.kt │ │ │ │ ├── update/ │ │ │ │ │ ├── efgs/ │ │ │ │ │ │ ├── EfgsUpdateFragment.kt │ │ │ │ │ │ └── EfgsUpdateVM.kt │ │ │ │ │ └── playservices/ │ │ │ │ │ ├── UpdatePlayServicesFragment.kt │ │ │ │ │ ├── UpdatePlayServicesVM.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── UpdatePlayServicesEvent.kt │ │ │ │ ├── verification/ │ │ │ │ │ ├── InvalidTokenException.kt │ │ │ │ │ ├── NoKeysException.kt │ │ │ │ │ ├── ReportExposureException.kt │ │ │ │ │ ├── VerificationFragment.kt │ │ │ │ │ ├── VerificationVM.kt │ │ │ │ │ ├── VerifyException.kt │ │ │ │ │ └── event/ │ │ │ │ │ └── VerificationCommandEvent.kt │ │ │ │ └── welcome/ │ │ │ │ ├── WelcomeFragment.kt │ │ │ │ ├── WelcomeVM.kt │ │ │ │ └── event/ │ │ │ │ └── WelcomeCommandEvent.kt │ │ │ └── utils/ │ │ │ ├── Analytics.kt │ │ │ ├── CustomTabHelper.kt │ │ │ ├── DeviceInfo.kt │ │ │ ├── L.kt │ │ │ ├── LocaleUtils.kt │ │ │ ├── Markdown.kt │ │ │ ├── MiscUtils.kt │ │ │ ├── SharedPrefsLiveData.kt │ │ │ ├── SupportEmailGenerator.kt │ │ │ ├── Text.kt │ │ │ └── ViewUtils.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── highlight_selector.xml │ │ │ ├── ic_about.xml │ │ │ ├── ic_ack_case.xml │ │ │ ├── ic_act_case.xml │ │ │ ├── ic_action_close.xml │ │ │ ├── ic_action_up.xml │ │ │ ├── ic_active.xml │ │ │ ├── ic_antigen.xml │ │ │ ├── ic_arrow_right.xml │ │ │ ├── ic_balloon.xml │ │ │ ├── ic_bluetooth_onboard.xml │ │ │ ├── ic_calendar.xml │ │ │ ├── ic_chat.xml │ │ │ ├── ic_confirm.xml │ │ │ ├── ic_contacts.xml │ │ │ ├── ic_control.xml │ │ │ ├── ic_cured.xml │ │ │ ├── ic_data.xml │ │ │ ├── ic_death_toll.xml │ │ │ ├── ic_encounter.xml │ │ │ ├── ic_error.xml │ │ │ ├── ic_eval.xml │ │ │ ├── ic_exposure_info.xml │ │ │ ├── ic_face_mask.xml │ │ │ ├── ic_google_play_services.xml │ │ │ ├── ic_help.xml │ │ │ ├── ic_home.xml │ │ │ ├── ic_hospitalized.xml │ │ │ ├── ic_how_it_works_banner.xml │ │ │ ├── ic_injection_complete.xml │ │ │ ├── ic_injection_first.xml │ │ │ ├── ic_item_empty.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_mask.xml │ │ │ ├── ic_mzcr.xml │ │ │ ├── ic_no_risky_encounter.xml │ │ │ ├── ic_notif.xml │ │ │ ├── ic_notifications_sent.xml │ │ │ ├── ic_notifications_shown.xml │ │ │ ├── ic_off_bluetooth.xml │ │ │ ├── ic_off_location.xml │ │ │ ├── ic_pause.xml │ │ │ ├── ic_positive.xml │ │ │ ├── ic_prevention.xml │ │ │ ├── ic_privacy.xml │ │ │ ├── ic_restriction.xml │ │ │ ├── ic_risky_encounter.xml │ │ │ ├── ic_shortcut_resume.xml │ │ │ ├── ic_splashscreen_hands.xml │ │ │ ├── ic_splashscreen_logo.xml │ │ │ ├── ic_symptoms.xml │ │ │ ├── ic_test.xml │ │ │ ├── ic_travel.xml │ │ │ ├── ic_update_expansion.xml │ │ │ ├── ic_vacc.xml │ │ │ ├── ic_warn.xml │ │ │ └── launchscreen.xml │ │ ├── drawable-anydpi-v24/ │ │ │ └── ic_notification_normal.xml │ │ ├── drawable-night/ │ │ │ └── ic_splashscreen_hands.xml │ │ ├── layout/ │ │ │ ├── activity_main.xml │ │ │ ├── activity_ragnarok.xml │ │ │ ├── dashboard_card_view.xml │ │ │ ├── fragment_about.xml │ │ │ ├── fragment_activation.xml │ │ │ ├── fragment_activation_notifications.xml │ │ │ ├── fragment_contacts.xml │ │ │ ├── fragment_dashboard_plus.xml │ │ │ ├── fragment_efgs.xml │ │ │ ├── fragment_efgs_agreement.xml │ │ │ ├── fragment_efgs_update.xml │ │ │ ├── fragment_error.xml │ │ │ ├── fragment_exposure.xml │ │ │ ├── fragment_exposure_help.xml │ │ │ ├── fragment_exposure_info.xml │ │ │ ├── fragment_help.xml │ │ │ ├── fragment_help_category.xml │ │ │ ├── fragment_help_question.xml │ │ │ ├── fragment_help_search.xml │ │ │ ├── fragment_how_it_works.xml │ │ │ ├── fragment_my_data.xml │ │ │ ├── fragment_no_verification_code.xml │ │ │ ├── fragment_play_services_update.xml │ │ │ ├── fragment_publish_success.xml │ │ │ ├── fragment_recent_exposures.xml │ │ │ ├── fragment_sandbox.xml │ │ │ ├── fragment_sandbox_config.xml │ │ │ ├── fragment_sandbox_data.xml │ │ │ ├── fragment_symptom_date.xml │ │ │ ├── fragment_traveller.xml │ │ │ ├── fragment_verification.xml │ │ │ ├── fragment_welcome.xml │ │ │ ├── item_contacts.xml │ │ │ ├── item_daily_summary.xml │ │ │ ├── item_exposure_help.xml │ │ │ ├── item_exposure_help_title.xml │ │ │ ├── item_exposure_window.xml │ │ │ ├── item_help_about_category.xml │ │ │ ├── item_help_faq_category.xml │ │ │ ├── item_help_how_category.xml │ │ │ ├── item_help_question.xml │ │ │ ├── item_recent_exposure.xml │ │ │ ├── item_recent_exposure_group_header.xml │ │ │ ├── item_scan_instance.xml │ │ │ ├── item_scan_instance_header.xml │ │ │ ├── item_search.xml │ │ │ ├── item_search_layout.xml │ │ │ ├── item_tek.xml │ │ │ ├── layout_sandbox_config_values.xml │ │ │ ├── search_toolbar.xml │ │ │ ├── traveller_dashboard_card_view.xml │ │ │ └── view_data_item.xml │ │ ├── menu/ │ │ │ ├── bottom_nav.xml │ │ │ ├── dashboard.xml │ │ │ ├── exposure.xml │ │ │ └── onboarding.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── navigation/ │ │ │ └── nav_graph.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── controls.xml │ │ │ ├── dimens.xml │ │ │ ├── ids.xml │ │ │ ├── strings-notranslate.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ └── themes.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ └── colors.xml │ │ ├── values-sk/ │ │ │ └── strings.xml │ │ ├── xml/ │ │ │ ├── file_paths.xml │ │ │ └── remote_config_defaults.xml │ │ ├── xml-cs/ │ │ │ └── remote_config_defaults.xml │ │ └── xml-sk/ │ │ └── remote_config_defaults.xml │ ├── prod/ │ │ ├── google-services.json │ │ └── res/ │ │ └── values/ │ │ └── controls.xml │ └── test/ │ └── kotlin/ │ └── com/ │ └── covid19cz/ │ └── bt_tracing/ │ └── ExampleUnitTest.kt ├── arch/ │ ├── build.gradle │ ├── gradle.properties │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── arch/ │ │ ├── BaseApp.kt │ │ ├── adapter/ │ │ │ ├── BaseRecyclerAdapter.kt │ │ │ ├── RecyclerLayoutStrategy.kt │ │ │ ├── SingleTypeRecyclerAdapter.kt │ │ │ └── StrategyRecyclerAdapter.kt │ │ ├── binding/ │ │ │ ├── EditTextBindings.kt │ │ │ ├── ImageViewBindings.kt │ │ │ ├── ProgressbarBindings.kt │ │ │ ├── RecyclerViewBindings.kt │ │ │ ├── TextViewBindings.kt │ │ │ ├── ViewBindings.kt │ │ │ └── WebViewBindings.kt │ │ ├── event/ │ │ │ ├── LiveEvent.kt │ │ │ ├── LiveEventMap.kt │ │ │ ├── NavigationEvent.kt │ │ │ ├── NavigationGraphEvent.kt │ │ │ └── SingleLiveEvent.java │ │ ├── extensions/ │ │ │ └── navExtensions.kt │ │ ├── livedata/ │ │ │ └── SafeMutableLiveData.kt │ │ ├── utils/ │ │ │ └── NullableUtils.kt │ │ ├── view/ │ │ │ ├── BaseArchActivity.kt │ │ │ ├── BaseArchDialogFragment.kt │ │ │ ├── BaseArchFragment.kt │ │ │ └── BaseDialogFragment.kt │ │ └── viewmodel/ │ │ ├── BaseArchViewModel.kt │ │ └── BaseDialogViewModel.kt │ └── res/ │ ├── layout/ │ │ ├── base_dialog.xml │ │ ├── base_view.xml │ │ └── item_recycler.xml │ └── values/ │ ├── ids.xml │ └── strings.xml ├── build.gradle ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── meta/ │ └── debug.keystore ├── release.sh └── settings.gradle
SYMBOL INDEX (4 symbols across 1 files)
FILE: arch/src/main/java/arch/event/SingleLiveEvent.java
class SingleLiveEvent (line 23) | public class SingleLiveEvent<T> extends MutableLiveData<T> {
method observe (line 29) | @MainThread
method setValue (line 48) | @MainThread
method call (line 57) | @MainThread
Condensed preview — 355 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,460K chars).
[
{
"path": ".github/pull_request_template.txt",
"chars": 865,
"preview": "# Description\n\n<# Please include a summary of the change and which issue is fixed. Please also include relevant motivati"
},
{
"path": ".github/stale.yml",
"chars": 681,
"preview": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 60\n# Number of days of inactivity before a "
},
{
"path": ".github/workflows/deploy-develop.yml",
"chars": 1480,
"preview": "name: Deploy production app\n\non:\n push:\n branches:\n - develop\n\njobs:\n build:\n\n runs-on: ubuntu-18.04\n\n ste"
},
{
"path": ".github/workflows/deploy-master.yml",
"chars": 1500,
"preview": "name: Deploy production app\n\non:\n push:\n branches:\n - master\n\njobs:\n build:\n\n runs-on: ubuntu-18.04\n\n step"
},
{
"path": ".gitignore",
"chars": 2405,
"preview": "# Created by https://www.gitignore.io/api/intellij,android,gradle,windows,osx\n\n### Intellij ###\n# Covers JetBrains IDEs:"
},
{
"path": ".idea/codeStyles/codeStyleConfig.xml",
"chars": 209,
"preview": "<component name=\"ProjectCodeStyleConfiguration\">\n <state>\n <option name=\"USE_PER_PROJECT_SETTINGS\" value=\"true\" />\n "
},
{
"path": "LICENSE",
"chars": 1097,
"preview": "MIT License\n\nCopyright (c) 2020 Ministry of Health of the Czech Republic\n\nPermission is hereby granted, free of charge, "
},
{
"path": "README.md",
"chars": 3793,
"preview": "# erouska-android\n\n[<img src=\"https://lh3.googleusercontent.com/cjsqrWQKJQp9RFO7-hJ9AfpKzbUb_Y84vXfjlP0iRHBvladwAfXih984"
},
{
"path": "app/build.gradle",
"chars": 7552,
"preview": "apply plugin: 'com.android.application'\napply plugin: 'dagger.hilt.android.plugin'\napply plugin: 'kotlin-android'\napply "
},
{
"path": "app/proguard-rules.pro",
"chars": 564,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/helpers/Actions.kt",
"chars": 3364,
"preview": "package cz.covid19cz.erouska.helpers\n\nimport android.app.Instrumentation\nimport android.content.Intent\nimport android.vi"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/helpers/ClickableLink.kt",
"chars": 101,
"preview": "package cz.covid19cz.erouska.helpers\n\nclass ClickableLink(var url: String, var clickableText: String)"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/helpers/TextMatchesIgnoringWhitespaceType.kt",
"chars": 777,
"preview": "package cz.covid19cz.erouska.helpers\n\nimport org.hamcrest.Description\nimport org.hamcrest.TypeSafeMatcher\n\nclass TextMat"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/A1Screen.kt",
"chars": 584,
"preview": "package cz.covid19cz.erouska.screens\n\nimport cz.covid19cz.erouska.R\nimport cz.covid19cz.erouska.helpers.checkDisplayed\ni"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/A2Screen.kt",
"chars": 1075,
"preview": "package cz.covid19cz.erouska.screens\n\nimport android.bluetooth.BluetoothAdapter\nimport cz.covid19cz.erouska.R\nimport cz."
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/A3Screen.kt",
"chars": 803,
"preview": "package cz.covid19cz.erouska.screens\n\nimport androidx.test.espresso.matcher.ViewMatchers.withId\nimport cz.covid19cz.erou"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/B1Screen.kt",
"chars": 643,
"preview": "package cz.covid19cz.erouska.screens\n\nimport cz.covid19cz.erouska.R\nimport cz.covid19cz.erouska.helpers.RETRY_TIMEOUT\nim"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/screens/N1Screen.kt",
"chars": 2856,
"preview": "package cz.covid19cz.erouska.screens\n\nimport androidx.test.espresso.matcher.ViewMatchers.withId\nimport cz.covid19cz.erou"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/testRules/DisableAnimationsRule.kt",
"chars": 2170,
"preview": "package cz.covid19cz.erouska.testRules\n\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.test.u"
},
{
"path": "app/src/androidTest/kotlin/cz/covid19cz/erouska/tests/ActivationTest.kt",
"chars": 1199,
"preview": "package cz.covid19cz.erouska.tests\n\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport androidx.test.rule.Activ"
},
{
"path": "app/src/dev/google-services.json",
"chars": 1349,
"preview": "{\n \"project_info\": {\n \"project_number\": \"382369682317\",\n \"firebase_url\": \"https://erouska-key-server-dev.firebase"
},
{
"path": "app/src/dev/res/drawable/ic_launcher_background.xml",
"chars": 3414,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"108dp\"\n android:height=\"108dp\"\n"
},
{
"path": "app/src/dev/res/drawable/ic_launcher_foreground.xml",
"chars": 1356,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"108dp\"\n android:height=\"108dp\"\n"
},
{
"path": "app/src/dev/res/values/controls.xml",
"chars": 556,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"uri_scheme\">erouska-dev</string>\n <string name=\""
},
{
"path": "app/src/dev/res/values/strings.xml",
"chars": 112,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"app_name\">eRouška DEV</string>\n</resources>"
},
{
"path": "app/src/main/AndroidManifest.xml",
"chars": 3635,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:to"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/App.kt",
"chars": 1464,
"preview": "package cz.covid19cz.erouska\n\nimport androidx.hilt.work.HiltWorkerFactory\nimport androidx.work.Configuration\nimport andr"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/AppConfig.kt",
"chars": 7729,
"preview": "package cz.covid19cz.erouska\n\nimport com.google.firebase.remoteconfig.FirebaseRemoteConfig\nimport com.google.firebase.re"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/DI.kt",
"chars": 1036,
"preview": "package cz.covid19cz.erouska\n\nimport android.content.Context\nimport androidx.room.Room\nimport com.google.android.gms.nea"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/db/DailySummariesDb.kt",
"chars": 274,
"preview": "package cz.covid19cz.erouska.db\n\nimport androidx.room.Database\nimport androidx.room.RoomDatabase\n\n@Database(version = 1,"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/db/DailySummaryDao.kt",
"chars": 1390,
"preview": "package cz.covid19cz.erouska.db\n\nimport androidx.room.Dao\nimport androidx.room.Insert\nimport androidx.room.OnConflictStr"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/db/DailySummaryEntity.kt",
"chars": 979,
"preview": "package cz.covid19cz.erouska.db\n\nimport androidx.room.ColumnInfo\nimport androidx.room.Entity\nimport androidx.room.Primar"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/db/SharedPrefsRepository.kt",
"chars": 18056,
"preview": "package cz.covid19cz.erouska.db\n\nimport android.content.Context\nimport android.content.Context.MODE_PRIVATE\nimport andro"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/ExposureCryptoTools.kt",
"chars": 1853,
"preview": "package cz.covid19cz.erouska.exposurenotifications\n\nimport android.util.Base64\nimport com.google.android.gms.nearby.expo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/ExposureNotificationsErrorHandling.kt",
"chars": 5677,
"preview": "package cz.covid19cz.erouska.exposurenotifications\n\nimport android.content.Context\nimport androidx.appcompat.app.AlertDi"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/ExposureNotificationsRepository.kt",
"chars": 16579,
"preview": "package cz.covid19cz.erouska.exposurenotifications\n\nimport android.content.Context\nimport androidx.work.Constraints\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/Notifications.kt",
"chars": 8672,
"preview": "package cz.covid19cz.erouska.exposurenotifications\n\nimport android.app.Notification\nimport android.app.NotificationChann"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/receiver/ExposureNotificationBroadcastReceiver.kt",
"chars": 1770,
"preview": "/*\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/service/PushService.kt",
"chars": 1503,
"preview": "package cz.covid19cz.erouska.exposurenotifications.service\n\nimport com.google.firebase.auth.FirebaseAuth\nimport com.goog"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/worker/DownloadKeysWorker.kt",
"chars": 2048,
"preview": "package cz.covid19cz.erouska.exposurenotifications.worker\n\nimport android.content.Context\nimport androidx.hilt.work.Hilt"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/exposurenotifications/worker/SelfCheckerWorker.kt",
"chars": 1444,
"preview": "package cz.covid19cz.erouska.exposurenotifications.worker\n\nimport android.content.Context\nimport androidx.hilt.work.Hilt"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/ByteArray.kt",
"chars": 268,
"preview": "package cz.covid19cz.erouska.ext\n\nval ByteArray.asHexLower inline get() = this.joinToString(separator = \"\"){ String.form"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/Context.kt",
"chars": 5125,
"preview": "package cz.covid19cz.erouska.ext\n\nimport android.app.Activity\nimport android.bluetooth.BluetoothManager\nimport android.c"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/Int.kt",
"chars": 468,
"preview": "package cz.covid19cz.erouska.ext\n\nimport java.text.SimpleDateFormat\nimport java.util.*\n\nfun Int.daysSinceEpochToDateStri"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/Long.kt",
"chars": 453,
"preview": "package cz.covid19cz.erouska.ext\n\nimport java.text.SimpleDateFormat\nimport java.util.*\n\nfun Long.timestampToDate(): Stri"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/Rx.kt",
"chars": 1453,
"preview": "package cz.covid19cz.erouska.ext\n\nimport io.reactivex.Flowable\nimport io.reactivex.Maybe\nimport io.reactivex.Observable\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/String.kt",
"chars": 117,
"preview": "package cz.covid19cz.erouska.ext\n\nfun String.toIntList() : List<Int>{\n return this.split(\",\").map { it.toInt() }\n}"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ext/View.kt",
"chars": 1208,
"preview": "package cz.covid19cz.erouska.ext\n\nimport android.content.Context\nimport android.view.View\nimport android.view.inputmetho"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/ExposureServerRepository.kt",
"chars": 9308,
"preview": "package cz.covid19cz.erouska.net\n\nimport android.content.Context\nimport androidx.work.*\nimport com.google.gson.Gson\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/FirebaseFunctionsRepository.kt",
"chars": 4728,
"preview": "package cz.covid19cz.erouska.net\n\nimport com.google.firebase.auth.FirebaseAuth\nimport com.google.firebase.functions.ktx."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/api/KeyServerApi.kt",
"chars": 344,
"preview": "package cz.covid19cz.erouska.net.api\n\nimport cz.covid19cz.erouska.net.model.ExposureRequest\nimport cz.covid19cz.erouska."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/api/VerificationServerApi.kt",
"chars": 496,
"preview": "package cz.covid19cz.erouska.net.api\n\nimport cz.covid19cz.erouska.net.model.*\nimport retrofit2.http.Body\nimport retrofit"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/exception/UnauthrorizedException.kt",
"chars": 86,
"preview": "package cz.covid19cz.erouska.net.exception\n\nclass UnauthrorizedException : Throwable()"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/model/CovidDataModel.kt",
"chars": 2696,
"preview": "package cz.covid19cz.erouska.net.model\n\nimport com.google.gson.annotations.SerializedName\n\ndata class CovidStatsResponse"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/model/DownloadedKeys.kt",
"chars": 468,
"preview": "package cz.covid19cz.erouska.net.model\n\nimport cz.covid19cz.erouska.utils.L\nimport java.io.File\n\ndata class DownloadedKe"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/model/KeyServerModel.kt",
"chars": 968,
"preview": "package cz.covid19cz.erouska.net.model\n\nimport android.util.Base64\nimport java.util.*\n\ndata class ExposureRequest(\n v"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/net/model/VerificationServerModel.kt",
"chars": 926,
"preview": "package cz.covid19cz.erouska.net.model\n\n// Taken from https://github.com/google/exposure-notifications-verification-serv"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/about/AboutFragment.kt",
"chars": 1961,
"preview": "package cz.covid19cz.erouska.ui.about\n\nimport android.os.Bundle\nimport android.view.View\nimport android.widget.Toast\nimp"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/about/AboutVM.kt",
"chars": 399,
"preview": "package cz.covid19cz.erouska.ui.about\n\nimport cz.covid19cz.erouska.AppConfig\nimport cz.covid19cz.erouska.ui.base.BaseVM\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/about/entity/AboutProfileItem.kt",
"chars": 172,
"preview": "package cz.covid19cz.erouska.ui.about.entity\n\nimport com.google.gson.annotations.SerializedName\n\nclass AboutProfileItem("
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationFragment.kt",
"chars": 5723,
"preview": "package cz.covid19cz.erouska.ui.activation\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.os."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationNotificationsFragment.kt",
"chars": 2411,
"preview": "package cz.covid19cz.erouska.ui.activation\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.os."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationNotificationsVM.kt",
"chars": 1113,
"preview": "package cz.covid19cz.erouska.ui.activation\n\nimport androidx.lifecycle.viewModelScope\nimport cz.covid19cz.erouska.exposur"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationState.kt",
"chars": 393,
"preview": "package cz.covid19cz.erouska.ui.activation\n\nimport arch.event.LiveEvent\n\nsealed class ActivationState\nobject ActivationS"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/activation/ActivationVM.kt",
"chars": 2332,
"preview": "package cz.covid19cz.erouska.ui.activation\n\nimport android.content.Context\nimport androidx.lifecycle.LiveData\nimport and"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/base/BaseActivity.kt",
"chars": 2508,
"preview": "package cz.covid19cz.erouska.ui.base\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.content.I"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/base/BaseFragment.kt",
"chars": 4457,
"preview": "package cz.covid19cz.erouska.ui.base\n\nimport android.app.Activity\nimport android.bluetooth.BluetoothAdapter\nimport andro"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/base/BaseVM.kt",
"chars": 1474,
"preview": "package cz.covid19cz.erouska.ui.base\n\nimport arch.viewmodel.BaseArchViewModel\nimport cz.covid19cz.erouska.ext.execute\nim"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/base/UrlEvent.kt",
"chars": 113,
"preview": "package cz.covid19cz.erouska.ui.base\n\nimport arch.event.LiveEvent\n\nclass UrlEvent(val url : String) : LiveEvent()"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/Contact.kt",
"chars": 156,
"preview": "package cz.covid19cz.erouska.ui.contacts\n\ndata class Contact(\n val title: String,\n val text: String,\n val linkT"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/ContactsFragment.kt",
"chars": 1898,
"preview": "package cz.covid19cz.erouska.ui.contacts\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.lifecycle.li"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/ContactsVM.kt",
"chars": 1129,
"preview": "package cz.covid19cz.erouska.ui.contacts\n\nimport androidx.databinding.ObservableArrayList\nimport androidx.lifecycle.Life"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/contacts/event/ContactsCommandEvent.kt",
"chars": 148,
"preview": "package cz.covid19cz.erouska.ui.contacts.event\n\nsealed class ContactsEvent {\n data class ContactLinkClicked(val link:"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/DashboardCardView.kt",
"chars": 3701,
"preview": "package cz.covid19cz.erouska.ui.dashboard\n\nimport android.content.Context\nimport android.graphics.drawable.Drawable\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/DashboardFragment.kt",
"chars": 15189,
"preview": "package cz.covid19cz.erouska.ui.dashboard\n\nimport android.app.Activity\nimport android.bluetooth.BluetoothAdapter\nimport "
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/DashboardVM.kt",
"chars": 8963,
"preview": "package cz.covid19cz.erouska.ui.dashboard\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.MutableLiveData"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/TravellerDashboardCardView.kt",
"chars": 838,
"preview": "package cz.covid19cz.erouska.ui.dashboard\n\nimport android.content.Context\nimport android.graphics.drawable.Drawable\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/event/DashboardCommandEvent.kt",
"chars": 360,
"preview": "package cz.covid19cz.erouska.ui.dashboard.event\n\nimport arch.event.LiveEvent\n\nclass DashboardCommandEvent(val command: C"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/event/DisabledEvent.kt",
"chars": 120,
"preview": "package cz.covid19cz.erouska.ui.dashboard.event\n\nimport arch.event.LiveEvent\n\nclass BluetoothDisabledEvent : LiveEvent()"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/dashboard/event/GmsApiErrorEvent.kt",
"chars": 140,
"preview": "package cz.covid19cz.erouska.ui.dashboard.event\n\nimport arch.event.LiveEvent\n\nclass GmsApiErrorEvent(val throwable: Thro"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/efgs/EfgsFragment.kt",
"chars": 1678,
"preview": "package cz.covid19cz.erouska.ui.efgs\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.appcompat.app.Al"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/efgs/EfgsVM.kt",
"chars": 797,
"preview": "package cz.covid19cz.erouska.ui.efgs\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.OnLifecycleEvent\nimp"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/efgs/event/EfgsCommandEvent.kt",
"chars": 202,
"preview": "package cz.covid19cz.erouska.ui.efgs.event\n\nimport arch.event.LiveEvent\n\nclass EfgsCommandEvent(val command: Command) : "
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/efgsagreement/EfgsAgreementFragment.kt",
"chars": 2209,
"preview": "package cz.covid19cz.erouska.ui.efgsagreement\n\nimport android.app.Activity\nimport android.content.Intent\nimport android."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/efgsagreement/EfgsAgreementVM.kt",
"chars": 3043,
"preview": "package cz.covid19cz.erouska.ui.efgsagreement\n\nimport androidx.lifecycle.viewModelScope\nimport arch.livedata.SafeMutable"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/error/ErrorFragment.kt",
"chars": 4446,
"preview": "package cz.covid19cz.erouska.ui.error\n\nimport android.os.Bundle\nimport android.view.MenuItem\nimport android.view.View\nim"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/error/ErrorVM.kt",
"chars": 226,
"preview": "package cz.covid19cz.erouska.ui.error\n\nimport cz.covid19cz.erouska.ui.base.BaseVM\nimport dagger.hilt.android.lifecycle.H"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/error/entity/ErrorType.kt",
"chars": 137,
"preview": "package cz.covid19cz.erouska.ui.error.entity\n\nenum class ErrorType {\n NO_INTERNET, GENERAL_ERROR, INVALID_CODE, EXPIR"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposure/ExposureFragment.kt",
"chars": 2952,
"preview": "package cz.covid19cz.erouska.ui.exposure\n\nimport android.os.Bundle\nimport android.view.Menu\nimport android.view.MenuInfl"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposure/ExposureVM.kt",
"chars": 2202,
"preview": "package cz.covid19cz.erouska.ui.exposure\n\nimport androidx.lifecycle.MutableLiveData\nimport androidx.lifecycle.viewModelS"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposure/event/ExposuresEvent.kt",
"chars": 231,
"preview": "package cz.covid19cz.erouska.ui.exposure.event\n\nimport arch.event.LiveEvent\n\nclass ExposuresCommandEvent(val command: Co"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposurehelp/ExposureHelpFragment.kt",
"chars": 1422,
"preview": "package cz.covid19cz.erouska.ui.exposurehelp\n\nimport android.os.Bundle\nimport androidx.navigation.fragment.navArgs\nimpor"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposurehelp/ExposureHelpVM.kt",
"chars": 1403,
"preview": "package cz.covid19cz.erouska.ui.exposurehelp\n\nimport androidx.databinding.ObservableArrayList\nimport arch.adapter.Recycl"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposurehelp/entity/ExposureHelpData.kt",
"chars": 150,
"preview": "package cz.covid19cz.erouska.ui.exposurehelp.entity\n\ndata class ExposureHelpData(\n val title: String?,\n val items:"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposurehelp/entity/ExposureHelpItem.kt",
"chars": 130,
"preview": "package cz.covid19cz.erouska.ui.exposurehelp.entity\n\ndata class ExposureHelpItem(\n val iconUrl: String,\n val label"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposurehelp/entity/ExposureHelpTitle.kt",
"chars": 96,
"preview": "package cz.covid19cz.erouska.ui.exposurehelp.entity\n\nclass ExposureHelpTitle(val title : String)"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposurehelp/entity/ExposureHelpType.kt",
"chars": 119,
"preview": "package cz.covid19cz.erouska.ui.exposurehelp.entity\n\nenum class ExposureHelpType {\n SYMPTOMS, PREVENTION, EXPOSURE\n}"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposureinfo/ExposureInfoFragment.kt",
"chars": 1236,
"preview": "package cz.covid19cz.erouska.ui.exposureinfo\n\nimport android.os.Bundle\nimport android.view.Menu\nimport android.view.Menu"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/exposureinfo/ExposureInfoVM.kt",
"chars": 1227,
"preview": "package cz.covid19cz.erouska.ui.exposureinfo\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.MutableLiveD"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/HelpFragment.kt",
"chars": 1931,
"preview": "package cz.covid19cz.erouska.ui.help\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.lifecycle.lifecy"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/HelpVM.kt",
"chars": 1857,
"preview": "package cz.covid19cz.erouska.ui.help\n\nimport androidx.databinding.ObservableArrayList\nimport arch.adapter.RecyclerLayout"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/data/AboutAppCategory.kt",
"chars": 77,
"preview": "package cz.covid19cz.erouska.ui.help.data\n\nclass AboutAppCategory : Category\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/data/Category.kt",
"chars": 62,
"preview": "package cz.covid19cz.erouska.ui.help.data\n\ninterface Category\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/data/FaqCategory.kt",
"chars": 842,
"preview": "package cz.covid19cz.erouska.ui.help.data\n\nimport android.os.Parcelable\nimport com.google.gson.Gson\nimport com.google.gs"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/data/HowToCategory.kt",
"chars": 79,
"preview": "package cz.covid19cz.erouska.ui.help.data\n\nclass HowItWorksCategory : Category\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/help/data/Question.kt",
"chars": 220,
"preview": "package cz.covid19cz.erouska.ui.help.data\n\nimport android.os.Parcelable\nimport kotlinx.android.parcel.Parcelize\n\n@Parcel"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpcategory/HelpCategoryFragment.kt",
"chars": 1298,
"preview": "package cz.covid19cz.erouska.ui.helpcategory\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.navigati"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpcategory/HelpCategoryVM.kt",
"chars": 1072,
"preview": "package cz.covid19cz.erouska.ui.helpcategory\n\nimport androidx.databinding.ObservableArrayList\nimport arch.adapter.Recycl"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpquestion/HelpQuestionFragment.kt",
"chars": 1083,
"preview": "package cz.covid19cz.erouska.ui.helpquestion\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.navigati"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpquestion/HelpQuestionVM.kt",
"chars": 636,
"preview": "package cz.covid19cz.erouska.ui.helpquestion\n\nimport androidx.databinding.ObservableArrayList\nimport arch.adapter.Recycl"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpsearch/HelpSearchFragment.kt",
"chars": 3642,
"preview": "package cz.covid19cz.erouska.ui.helpsearch\n\nimport android.app.SearchManager\nimport android.content.Context\nimport andro"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpsearch/HelpSearchVM.kt",
"chars": 4263,
"preview": "package cz.covid19cz.erouska.ui.helpsearch\n\nimport androidx.databinding.ObservableArrayList\nimport androidx.lifecycle.vi"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpsearch/data/SearchableQuestion.kt",
"chars": 166,
"preview": "package cz.covid19cz.erouska.ui.helpsearch.data\n\ndata class SearchableQuestion(\n val category: String,\n var questi"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/helpsearch/ui/SearchableItem.kt",
"chars": 1278,
"preview": "package cz.covid19cz.erouska.ui.helpsearch.ui\n\nimport android.content.Context\nimport android.util.AttributeSet\nimport an"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/how/HowItWorksFragment.kt",
"chars": 1915,
"preview": "package cz.covid19cz.erouska.ui.how\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.lifecycle.lifecyc"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/how/HowItWorksVM.kt",
"chars": 763,
"preview": "package cz.covid19cz.erouska.ui.how\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.OnLifecycleEvent\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/how/event/HowItWorksEvent.kt",
"chars": 200,
"preview": "package cz.covid19cz.erouska.ui.how.event\n\nimport arch.event.LiveEvent\n\nclass HowItWorksEvent(val command: Command) : Li"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/main/MainActivity.kt",
"chars": 999,
"preview": "package cz.covid19cz.erouska.ui.main\n\nimport android.content.Intent\nimport android.net.Uri\nimport android.os.Bundle\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/main/MainActivityOld.kt",
"chars": 7215,
"preview": "package cz.covid19cz.erouska.ui.main\n\nimport android.content.ComponentName\nimport android.content.Intent\nimport android."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/main/MainVM.kt",
"chars": 837,
"preview": "package cz.covid19cz.erouska.ui.main\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.OnLifecycleEvent\nimp"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/mydata/CaseItemView.kt",
"chars": 1232,
"preview": "package cz.covid19cz.erouska.ui.mydata\n\nimport android.content.Context\nimport android.graphics.drawable.Drawable\nimport "
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/mydata/MyDataFragment.kt",
"chars": 1303,
"preview": "package cz.covid19cz.erouska.ui.mydata\n\nimport android.os.Bundle\nimport android.view.View\nimport cz.covid19cz.erouska.Ap"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/mydata/MyDataVM.kt",
"chars": 15823,
"preview": "package cz.covid19cz.erouska.ui.mydata\n\nimport android.text.format.DateUtils\nimport androidx.lifecycle.Lifecycle\nimport "
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/noverificationcode/NoVerificationCodeFragment.kt",
"chars": 1479,
"preview": "package cz.covid19cz.erouska.ui.noverificationcode\n\nimport android.os.Bundle\nimport android.view.View\nimport cz.covid19c"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/noverificationcode/NoVerificationCodeVM.kt",
"chars": 393,
"preview": "package cz.covid19cz.erouska.ui.noverificationcode\n\nimport cz.covid19cz.erouska.ui.base.BaseVM\nimport cz.covid19cz.erous"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/noverificationcode/event/NoVerificationCodeEvent.kt",
"chars": 122,
"preview": "package cz.covid19cz.erouska.ui.noverificationcode.event\n\nimport arch.event.LiveEvent\n\nclass WriteEmailEvent : LiveEvent"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/permissions/BasePermissionsFragment.kt",
"chars": 0,
"preview": ""
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/publishsuccess/PublishSuccessFragment.kt",
"chars": 350,
"preview": "package cz.covid19cz.erouska.ui.publishsuccess\n\nimport cz.covid19cz.erouska.R\nimport cz.covid19cz.erouska.databinding.Fr"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/publishsuccess/PublishSuccessVM.kt",
"chars": 281,
"preview": "package cz.covid19cz.erouska.ui.publishsuccess\n\nimport cz.covid19cz.erouska.ui.base.BaseVM\n\nclass PublishSuccessVM : Bas"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/ragnarok/RagnarokVM.kt",
"chars": 1055,
"preview": "package cz.covid19cz.erouska.ui.ragnarok\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.MutableLiveData\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/recentexposures/RecentExposuresFragment.kt",
"chars": 783,
"preview": "package cz.covid19cz.erouska.ui.recentexposures\n\nimport android.os.Bundle\nimport android.view.View\nimport cz.covid19cz.e"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/recentexposures/RecentExposuresVM.kt",
"chars": 4743,
"preview": "package cz.covid19cz.erouska.ui.recentexposures\n\nimport androidx.databinding.ObservableArrayList\nimport androidx.lifecyc"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/recentexposures/entity/RecentExposureGroupHeaderItem.kt",
"chars": 382,
"preview": "package cz.covid19cz.erouska.ui.recentexposures.entity\n\nimport cz.covid19cz.erouska.ext.timestampToDate\nimport cz.covid1"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxConfigFragment.kt",
"chars": 692,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport android.os.Bundle\nimport cz.covid19cz.erouska.R\nimport cz.covid19cz.erou"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxConfigVM.kt",
"chars": 2217,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.OnLifecycleEvent\n"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxConfigValues.kt",
"chars": 1636,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport androidx.lifecycle.MutableLiveData\n\nclass SandboxConfigValues(val title:"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxDataFragment.kt",
"chars": 1319,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport android.os.Bundle\nimport android.view.View\nimport arch.adapter.RecyclerL"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxDataVM.kt",
"chars": 2274,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport androidx.databinding.ObservableArrayList\nimport androidx.lifecycle.Lifec"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxFragment.kt",
"chars": 1539,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport android.app.Activity\nimport android.content.Intent\nimport android.os.Bun"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/SandboxVM.kt",
"chars": 4948,
"preview": "package cz.covid19cz.erouska.ui.sandbox\n\nimport android.util.Base64\nimport androidx.databinding.ObservableArrayList\nimpo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/sandbox/event/SnackbarEvent.kt",
"chars": 128,
"preview": "package cz.covid19cz.erouska.ui.sandbox.event\n\nimport arch.event.LiveEvent\n\nclass SnackbarEvent(val text : String) : Liv"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/symptomdate/SymptomDateFragment.kt",
"chars": 2448,
"preview": "package cz.covid19cz.erouska.ui.symptomdate\n\nimport android.app.DatePickerDialog\nimport android.os.Bundle\nimport android"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/symptomdate/SymptomDateVM.kt",
"chars": 1654,
"preview": "package cz.covid19cz.erouska.ui.symptomdate\n\nimport androidx.lifecycle.MutableLiveData\nimport arch.livedata.SafeMutableL"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/symptomdate/event/DatePickerEvent.kt",
"chars": 157,
"preview": "package cz.covid19cz.erouska.ui.symptomdate.event\n\nimport arch.event.LiveEvent\nimport java.util.*\n\nclass DatePickerEvent"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/symptomdate/event/SymptomDateCommandEvent.kt",
"chars": 252,
"preview": "package cz.covid19cz.erouska.ui.symptomdate.event\n\nimport arch.event.LiveEvent\nimport java.util.*\n\nclass SymptomDateComm"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/traveller/TravellerFragment.kt",
"chars": 382,
"preview": "package cz.covid19cz.erouska.ui.traveller\n\nimport cz.covid19cz.erouska.R\nimport cz.covid19cz.erouska.databinding.Fragmen"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/traveller/TravellerVM.kt",
"chars": 492,
"preview": "package cz.covid19cz.erouska.ui.traveller\n\nimport cz.covid19cz.erouska.db.SharedPrefsRepository\nimport cz.covid19cz.erou"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/update/efgs/EfgsUpdateFragment.kt",
"chars": 2205,
"preview": "package cz.covid19cz.erouska.ui.update.efgs\n\nimport android.os.Bundle\nimport android.view.View\nimport androidx.appcompat"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/update/efgs/EfgsUpdateVM.kt",
"chars": 502,
"preview": "package cz.covid19cz.erouska.ui.update.efgs\n\nimport arch.viewmodel.BaseArchViewModel\nimport cz.covid19cz.erouska.AppConf"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/update/playservices/UpdatePlayServicesFragment.kt",
"chars": 2010,
"preview": "package cz.covid19cz.erouska.ui.update.playservices\n\nimport android.content.Intent\nimport android.net.Uri\nimport android"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/update/playservices/UpdatePlayServicesVM.kt",
"chars": 453,
"preview": "package cz.covid19cz.erouska.ui.update.playservices\n\nimport cz.covid19cz.erouska.ui.base.BaseVM\nimport cz.covid19cz.erou"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/update/playservices/event/UpdatePlayServicesEvent.kt",
"chars": 209,
"preview": "package cz.covid19cz.erouska.ui.update.playservices.event\n\nimport arch.event.LiveEvent\n\nclass UpdatePlayServicesEvent(va"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/InvalidTokenException.kt",
"chars": 87,
"preview": "package cz.covid19cz.erouska.ui.verification\n\nclass InvalidTokenException : Throwable()"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/NoKeysException.kt",
"chars": 81,
"preview": "package cz.covid19cz.erouska.ui.verification\n\nclass NoKeysException : Throwable()"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/ReportExposureException.kt",
"chars": 127,
"preview": "package cz.covid19cz.erouska.ui.verification\n\nclass ReportExposureException(val error: String, val code: String?) : Thro"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/VerificationFragment.kt",
"chars": 1849,
"preview": "package cz.covid19cz.erouska.ui.verification\n\nimport android.os.Bundle\nimport android.view.View\nimport cz.covid19cz.erou"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/VerificationVM.kt",
"chars": 4153,
"preview": "package cz.covid19cz.erouska.ui.verification\n\nimport androidx.core.text.isDigitsOnly\nimport androidx.lifecycle.MutableLi"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/VerifyException.kt",
"chars": 126,
"preview": "package cz.covid19cz.erouska.ui.verification\n\nclass VerifyException(message : String?, val code: String?) : Throwable(me"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/verification/event/VerificationCommandEvent.kt",
"chars": 246,
"preview": "package cz.covid19cz.erouska.ui.verification.event\n\nimport arch.event.LiveEvent\nimport java.util.*\n\nclass VerificationCo"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/welcome/WelcomeFragment.kt",
"chars": 1911,
"preview": "package cz.covid19cz.erouska.ui.welcome\n\nimport android.os.Bundle\nimport android.text.method.LinkMovementMethod\nimport a"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/welcome/WelcomeVM.kt",
"chars": 589,
"preview": "package cz.covid19cz.erouska.ui.welcome\n\nimport cz.covid19cz.erouska.db.SharedPrefsRepository\nimport cz.covid19cz.erousk"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/ui/welcome/event/WelcomeCommandEvent.kt",
"chars": 206,
"preview": "package cz.covid19cz.erouska.ui.welcome.event\n\nimport arch.event.LiveEvent\n\nclass WelcomeCommandEvent(val command: Comma"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/Analytics.kt",
"chars": 855,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.content.Context\nimport android.os.Bundle\nimport com.google.firebase.a"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/CustomTabHelper.kt",
"chars": 3733,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.content.Context\nimport android.content.Intent\nimport android.content."
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/DeviceInfo.kt",
"chars": 2197,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.annotation.SuppressLint\nimport android.bluetooth.BluetoothAdapter\nimp"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/L.kt",
"chars": 1702,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.util.Log\nimport com.google.firebase.crashlytics.FirebaseCrashlytics\ni"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/LocaleUtils.kt",
"chars": 460,
"preview": "package cz.covid19cz.erouska.utils\n\nimport cz.covid19cz.erouska.BuildConfig\nimport java.util.*\n\nobject LocaleUtils {\n\n "
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/Markdown.kt",
"chars": 4107,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.content.Context\nimport android.text.style.BackgroundColorSpan\nimport "
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/MiscUtils.kt",
"chars": 372,
"preview": "package cz.covid19cz.erouska.utils\n\nimport java.text.DecimalFormat\nimport java.text.NumberFormat\n\nobject SignNumberForma"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/SharedPrefsLiveData.kt",
"chars": 7620,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.content.SharedPreferences\nimport android.content.SharedPreferences.Ed"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/SupportEmailGenerator.kt",
"chars": 7204,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.app.Activity\nimport android.content.Context\nimport android.net.Uri\nim"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/Text.kt",
"chars": 2167,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.content.Context\nimport android.widget.TextView\nimport androidx.annota"
},
{
"path": "app/src/main/kotlin/cz/covid19cz/erouska/utils/ViewUtils.kt",
"chars": 239,
"preview": "package cz.covid19cz.erouska.utils\n\nimport android.view.View\nimport cz.covid19cz.erouska.ext.hide\nimport cz.covid19cz.er"
},
{
"path": "app/src/main/res/drawable/highlight_selector.xml",
"chars": 342,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<ripple xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:co"
},
{
"path": "app/src/main/res/drawable/ic_about.xml",
"chars": 386,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"20dp\"\n android:height=\"20dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_ack_case.xml",
"chars": 3520,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_act_case.xml",
"chars": 4602,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_action_close.xml",
"chars": 381,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_action_up.xml",
"chars": 349,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_active.xml",
"chars": 1621,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"42dp\"\n android:height=\"42dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_antigen.xml",
"chars": 1979,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_arrow_right.xml",
"chars": 356,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_balloon.xml",
"chars": 9827,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"180dp\"\n android:height=\"180dp\"\n"
},
{
"path": "app/src/main/res/drawable/ic_bluetooth_onboard.xml",
"chars": 20408,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"130dp\"\n android:height=\"130dp\"\n"
},
{
"path": "app/src/main/res/drawable/ic_calendar.xml",
"chars": 445,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_chat.xml",
"chars": 422,
"preview": "<vector android:height=\"24dp\" android:tint=\"#757575\"\n android:viewportHeight=\"24.0\" android:viewportWidth=\"24.0\"\n "
},
{
"path": "app/src/main/res/drawable/ic_confirm.xml",
"chars": 14179,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:aapt=\"http://schemas.android.com/aapt\"\n "
},
{
"path": "app/src/main/res/drawable/ic_contacts.xml",
"chars": 609,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_control.xml",
"chars": 10386,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:aapt=\"http://schemas.android.com/aapt\"\n "
},
{
"path": "app/src/main/res/drawable/ic_cured.xml",
"chars": 3616,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_data.xml",
"chars": 590,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_death_toll.xml",
"chars": 701,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_encounter.xml",
"chars": 19146,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:aapt=\"http://schemas.android.com/aapt\"\n "
},
{
"path": "app/src/main/res/drawable/ic_error.xml",
"chars": 1043,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"130dp\"\n android:height=\"130dp\"\n"
},
{
"path": "app/src/main/res/drawable/ic_eval.xml",
"chars": 29381,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n xmlns:aapt=\"http://schemas.android.com/aapt\"\n "
},
{
"path": "app/src/main/res/drawable/ic_exposure_info.xml",
"chars": 11351,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"130dp\"\n android:height=\"130dp\"\n"
},
{
"path": "app/src/main/res/drawable/ic_face_mask.xml",
"chars": 1916,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_google_play_services.xml",
"chars": 9160,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"200dp\"\n android:height=\"200dp\"\n"
},
{
"path": "app/src/main/res/drawable/ic_help.xml",
"chars": 626,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_home.xml",
"chars": 354,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
}
]
// ... and 155 more files (download for full content)
About this extraction
This page contains the full source code of the covid19cz/erouska-android GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 355 files (1.3 MB), approximately 435.7k tokens, and a symbol index with 4 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.